Provide managed generated artifact storage

This commit is contained in:
2026-08-02 12:38:15 +02:00
parent 359c4e9570
commit b993d8e31a
6 changed files with 228 additions and 2 deletions
+6
View File
@@ -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.
+16
View File
@@ -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.
+87 -1
View File
@@ -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,
+29 -1
View File
@@ -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=(
+89
View File
@@ -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()
+1
View File
@@ -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",
}