diff --git a/README.md b/README.md index fae344c..42f4009 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,12 @@ user or group file space with `owner_type` and `owner_id`. The storage layer keeps a named legacy file-only helper for historical callers that lack owner context, and regression tests cover its write-access checks. +Optional producer modules can persist generated output through the provider- +neutral `files.artifact_store` capability. Files remains responsible for upload +authorization, ownership, path normalization, versioning, and blob storage; +producers receive stable file/version references without importing Files +internals. See [Generated Artifact Store](docs/GENERATED_ARTIFACT_STORE.md). + ## Release packaging The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. diff --git a/docs/GENERATED_ARTIFACT_STORE.md b/docs/GENERATED_ARTIFACT_STORE.md new file mode 100644 index 0000000..7aa8b0e --- /dev/null +++ b/docs/GENERATED_ARTIFACT_STORE.md @@ -0,0 +1,16 @@ +# Generated Artifact Store + +Files implements Core's optional `files.artifact_store` capability for modules +that generate deterministic output without owning file storage. + +The producer supplies bytes, filename, content type, destination folder, +optional idempotency key, and bounded non-secret provenance. Files applies the +actor's `files:file:upload` permission, tenant/user ownership, path rules, +versioning, configured blob backend, and conflict behavior. An idempotency key +is represented as source provenance so an unchanged retry does not create an +unrelated file version. + +The response contains only file/version identifiers, display path, media type, +size, digest, and storage provenance. Producers must not put credentials, +tokens, or rendered plaintext into metadata. Storing an artifact proves Files +accepted it; it does not prove printing, mailing, or any other external effect. diff --git a/src/govoplan_files/backend/capabilities.py b/src/govoplan_files/backend/capabilities.py index 5d35c2a..cde7cab 100644 --- a/src/govoplan_files/backend/capabilities.py +++ b/src/govoplan_files/backend/capabilities.py @@ -8,6 +8,11 @@ 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 ( + 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 @@ -20,7 +25,11 @@ from govoplan_files.backend.storage.campaign_attachments import ( share_assets_with_campaign, ) from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs -from govoplan_files.backend.storage.files import current_version_and_blob +from govoplan_files.backend.storage.files import ( + create_file_asset, + current_version_and_blob, + sync_file_asset_from_source, +) from govoplan_files.backend.storage.paths import normalize_folder from govoplan_files.backend.storage.share_state import effective_file_share_clause @@ -60,6 +69,83 @@ def campaign_capability(context: ModuleContext) -> FilesCampaignCapability: return FilesCampaignCapability() +class FilesArtifactStore(ManagedArtifactStore): + def store_artifact( + self, + session: object, + principal: object, + *, + request: ManagedArtifactWriteRequest, + ) -> ManagedArtifactRef: + if not hasattr(session, "query") or not hasattr(session, "flush"): + raise TypeError("Files artifact storage requires a SQLAlchemy session.") + if not hasattr(principal, "has") or not principal.has("files:file:upload"): + raise PermissionError("Managed artifact storage requires files:file:upload.") + user = getattr(principal, "user", None) + user_id = str(getattr(user, "id", "") or "") + tenant_id = str(getattr(principal, "tenant_id", "") or "") + if not user_id or not tenant_id: + raise PermissionError("Managed artifact storage requires a tenant user principal.") + metadata = dict(request.metadata) + if request.idempotency_key: + metadata["source_provenance"] = { + "source_type": "generated_artifact", + "connector_id": "files.artifact_store", + "provider": str(metadata.get("producer_module") or "platform"), + "external_id": request.idempotency_key, + "revision": str(metadata.get("output_sha256") or "") or None, + } + stored, _action, _previous_version_id = sync_file_asset_from_source( + session, + tenant_id=tenant_id, + owner_type="user", + owner_id=user_id, + user_id=user_id, + filename=request.filename, + data=request.payload, + metadata=metadata, + folder=request.folder, + content_type=request.content_type, + conflict_strategy="rename", + is_admin=principal.has("files:file:admin"), + ) + else: + stored = create_file_asset( + session, + tenant_id=tenant_id, + owner_type="user", + owner_id=user_id, + user_id=user_id, + filename=request.filename, + data=request.payload, + folder=request.folder, + content_type=request.content_type, + description=request.description, + metadata=metadata, + conflict_strategy="rename", + is_admin=principal.has("files:file:admin"), + ) + return ManagedArtifactRef( + file_asset_id=stored.asset.id, + file_version_id=stored.version.id, + filename=stored.version.filename_at_upload, + display_path=stored.asset.display_path, + content_type=stored.version.content_type or request.content_type, + size_bytes=stored.version.size_bytes, + sha256=stored.version.checksum_sha256, + provenance={ + "module": "files", + "owner_type": stored.asset.owner_type, + "managed": True, + }, + ) + + +def artifact_store_capability(context: ModuleContext) -> FilesArtifactStore: + configure_runtime(registry=context.registry, settings=context.settings) + return FilesArtifactStore() + + class FilesAccessService(FileAccessProvider): def explain_resource_provenance( self, diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 169ff41..18c4413 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -7,7 +7,10 @@ from sqlalchemy import inspect from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER -from govoplan_core.core.files import CAPABILITY_FILES_ACCESS +from govoplan_core.core.files import ( + CAPABILITY_FILES_ACCESS, + CAPABILITY_FILES_ARTIFACT_STORE, +) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( DocumentationCondition, @@ -280,6 +283,7 @@ manifest = ModuleManifest( 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"), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -792,6 +796,29 @@ manifest = ModuleManifest( "verification": "Run the Files storage round-trip check and a coordinated restore drill against the exact deployment topology.", }, ), + DocumentationTopic( + id="files.reference.generated-artifact-store", + title="Store generated module artifacts", + summary="Let optional producer modules persist generated output through the Files authority boundary.", + body="The files.artifact_store capability accepts generated bytes plus bounded non-secret provenance, applies Files upload authorization, ownership, path, version, and blob-storage rules, and returns provider-neutral file/version references. Idempotency uses source provenance. Artifact acceptance does not prove printing, mailing, or another external effect.", + layer="available", + documentation_types=("admin", "user"), + audience=("file_admin", "operator", "module_admin", "integrator"), + order=55, + conditions=( + DocumentationCondition( + required_modules=("files",), + any_scopes=("files:file:upload", "files:file:admin"), + ), + ), + links=( + DocumentationLink(label="Files", href="/files", kind="runtime"), + DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), + DocumentationLink(label="Generated artifact contract", href="govoplan-files/docs/GENERATED_ARTIFACT_STORE.md", kind="repository"), + ), + related_modules=("templates", "campaigns", "reporting"), + metadata={"kind": "reference", "route": "/files"}, + ), ), documentation_providers=(documentation_topics,), migration_spec=MigrationSpec( @@ -819,6 +846,7 @@ manifest = ModuleManifest( ), capability_factories={ 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), "files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), }, operational_check_providers=( diff --git a/tests/test_artifact_store.py b/tests/test_artifact_store.py new file mode 100644 index 0000000..87882f2 --- /dev/null +++ b/tests/test_artifact_store.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.files import ManagedArtifactStore, ManagedArtifactWriteRequest +from govoplan_files.backend.capabilities import FilesArtifactStore + + +def principal() -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset({"files:file:upload"}), + ), + account=object(), + user=SimpleNamespace(id="user-1"), + ) + + +class _Session: + def query(self): + raise AssertionError("not used") + + def flush(self): + raise AssertionError("not used") + + +def stored(): + return SimpleNamespace( + asset=SimpleNamespace( + id="file-1", + display_path="Generated/Templates/result.html", + owner_type="user", + ), + version=SimpleNamespace( + id="version-1", + filename_at_upload="result.html", + content_type="text/html", + size_bytes=6, + checksum_sha256="0" * 64, + ), + ) + + +class FilesArtifactStoreTests(unittest.TestCase): + def test_plain_write_uses_files_authority_and_returns_provider_neutral_ref(self) -> None: + service = FilesArtifactStore() + self.assertIsInstance(service, ManagedArtifactStore) + request = ManagedArtifactWriteRequest( + filename="result.html", + payload=b"result", + content_type="text/html", + folder="Generated/Templates", + metadata={"producer_module": "templates"}, + ) + with patch("govoplan_files.backend.capabilities.create_file_asset", return_value=stored()) as create: + result = service.store_artifact(_Session(), principal(), request=request) + self.assertEqual("file-1", result.file_asset_id) + self.assertEqual("version-1", result.file_version_id) + self.assertEqual("tenant-1", create.call_args.kwargs["tenant_id"]) + self.assertEqual("user-1", create.call_args.kwargs["owner_id"]) + self.assertEqual(b"result", create.call_args.kwargs["data"]) + + def test_idempotent_write_uses_source_provenance_without_payload_metadata(self) -> None: + request = ManagedArtifactWriteRequest( + filename="result.html", + payload=b"secret body", + content_type="text/html", + idempotency_key="render-1", + metadata={"producer_module": "templates", "output_sha256": "a" * 64}, + ) + with patch( + "govoplan_files.backend.capabilities.sync_file_asset_from_source", + return_value=(stored(), "unchanged", None), + ) as sync: + FilesArtifactStore().store_artifact(_Session(), principal(), request=request) + metadata = sync.call_args.kwargs["metadata"] + self.assertEqual("render-1", metadata["source_provenance"]["external_id"]) + self.assertNotIn("secret body", str(metadata)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest_documentation.py b/tests/test_manifest_documentation.py index 02a822c..4af012a 100644 --- a/tests/test_manifest_documentation.py +++ b/tests/test_manifest_documentation.py @@ -11,6 +11,7 @@ STATIC_TOPIC_IDS = { "files.governed-connectors-and-provenance", "files.reference.integrity-recovery-and-fail-closed-transports", "files.reference.shared-storage-profile", + "files.reference.generated-artifact-store", "files.reference.snapshot-provenance-and-capabilities", "files.assurance.process-and-release-readiness", }