Provide managed generated artifact storage
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user