Restrict rendered template artifacts

This commit is contained in:
2026-08-02 13:58:45 +02:00
parent b65b905b6e
commit 3551c48e14
6 changed files with 91 additions and 18 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ The first operational slice provides:
- immutable template/input/output hashes, renderer version, item/page counts, - immutable template/input/output hashes, renderer version, item/page counts,
diagnostics, and idempotent final-output evidence; diagnostics, and idempotent final-output evidence;
- optional managed artifact persistence through the Core Files contract, with - optional managed artifact persistence through the Core Files contract, with
a bounded Templates download when Files is absent; and an actor-scoped bounded Templates download when Files is absent; and
- a full-height library/editor/preview WebUI using the shared rich-text editor. - a full-height library/editor/preview WebUI using the shared rich-text editor.
The module has no hard dependency on its consumers or on Files. The module has no hard dependency on its consumers or on Files.
+6
View File
@@ -24,6 +24,12 @@ template, input, and output hashes. Otherwise Templates stores a bounded
database payload. Review database and Files retention together before deleting database payload. Review database and Files retention together before deleting
render evidence. render evidence.
Without Files, output payloads are bounded and retained by Templates. Ordinary
users can list and download only output they rendered themselves; a principal
with `templates:template:admin` can inspect all tenant render evidence. Consumer
modules must not redistribute the Templates download URL directly when their
resource access rules differ.
## Operations ## Operations
Apply the module Alembic migration before startup. Monitor rejected renders for Apply the module Alembic migration before startup. Monitor rejected renders for
+6 -4
View File
@@ -29,7 +29,7 @@ reference; Templates does not fetch or silently refresh that source.
Files optionally implements `files.artifact_store`. A final render can request Files optionally implements `files.artifact_store`. A final render can request
managed persistence through that contract. If Files is absent, incompatible, managed persistence through that contract. If Files is absent, incompatible,
or unauthorized, the result carries a warning and remains available through a or unauthorized, the result carries a warning and remains available through a
5 MiB bounded Templates download. Managed output is not duplicated in the 5 MiB actor-scoped Templates download. Managed output is not duplicated in the
Templates payload column. Templates payload column.
## Safety And Determinism ## Safety And Determinism
@@ -50,6 +50,8 @@ Templates payload column.
Template definitions, revisions, render evidence, and bounded output are in the Template definitions, revisions, render evidence, and bounded output are in the
shared database and therefore follow platform backup and restore. Managed Files shared database and therefore follow platform backup and restore. Managed Files
artifacts follow Files recovery. Retiring the module is destructive only after artifacts follow Files recovery. Bounded render payloads and history are visible
the installer captures a database snapshot; consumers retain pinned hashes and only to their creator or a Templates administrator; consumers provide a
must diagnose the now-unavailable provider. resource-governed proxy when collaborators need access. Retiring the module is
destructive only after the installer captures a database snapshot; consumers
retain pinned hashes and must diagnose the now-unavailable provider.
+3
View File
@@ -14,6 +14,9 @@ Open **Templates** to create or select a reusable definition.
mismatches before output is produced. mismatches before output is produced.
6. Preview a draft or render final output. Store it in Files when that module is 6. Preview a draft or render final output. Store it in Files when that module is
available and you have upload permission; otherwise use the bounded download. available and you have upload permission; otherwise use the bounded download.
Bounded output history is visible only to the actor who rendered it and to a
Templates administrator. Calling modules expose their own governed download
when additional collaborators need access.
Render evidence shows the exact revision and abbreviated template, input, and Render evidence shows the exact revision and abbreviated template, input, and
output hashes. A consumer such as Campaign can submit many frozen recipients; output hashes. A consumer such as Campaign can submit many frozen recipients;
@@ -31,6 +31,7 @@ from govoplan_templates.backend.db.models import (
TemplateRevision, TemplateRevision,
) )
from govoplan_templates.backend.service import ( from govoplan_templates.backend.service import (
ADMIN_SCOPE,
RENDER_SCOPE, RENDER_SCOPE,
compatibility, compatibility,
get_template, get_template,
@@ -199,6 +200,14 @@ def get_render_for_principal(
if row is None: if row is None:
raise TemplateRenderError("Template render not found.") raise TemplateRenderError("Template render not found.")
get_template(session, principal, row.template_id) get_template(session, principal, row.template_id)
if (
row.created_by_account_id != principal.account_id
and not principal.has(ADMIN_SCOPE)
):
# Render payloads may contain recipient-specific or otherwise
# confidential data. Do not reveal whether another actor's render
# exists to ordinary template readers.
raise TemplateRenderError("Template render not found.")
return row return row
@@ -212,6 +221,10 @@ def list_renders(
statement = select(TemplateRender).where( statement = select(TemplateRender).where(
TemplateRender.tenant_id == principal.tenant_id TemplateRender.tenant_id == principal.tenant_id
) )
if not principal.has(ADMIN_SCOPE):
statement = statement.where(
TemplateRender.created_by_account_id == principal.account_id
)
if template_id: if template_id:
get_template(session, principal, template_id) get_template(session, principal, template_id)
statement = statement.where(TemplateRender.template_id == template_id) statement = statement.where(TemplateRender.template_id == template_id)
+62 -13
View File
@@ -10,6 +10,7 @@ from govoplan_core.core.templates import (
CAPABILITY_TEMPLATE_CATALOG, CAPABILITY_TEMPLATE_CATALOG,
CAPABILITY_TEMPLATE_RENDERER, CAPABILITY_TEMPLATE_RENDERER,
TemplateCompatibilityError, TemplateCompatibilityError,
TemplateRenderError,
TemplateRenderRequest, TemplateRenderRequest,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -20,7 +21,11 @@ from govoplan_templates.backend.db.models import (
TemplateRender, TemplateRender,
TemplateRevision, TemplateRevision,
) )
from govoplan_templates.backend.rendering import render_template from govoplan_templates.backend.rendering import (
get_render_for_principal,
list_renders,
render_template,
)
from govoplan_templates.backend.schemas import ( from govoplan_templates.backend.schemas import (
TemplateCreateRequest, TemplateCreateRequest,
TemplateUpdateRequest, TemplateUpdateRequest,
@@ -33,23 +38,28 @@ from govoplan_templates.backend.service import (
) )
def principal(tenant_id: str = "tenant-1") -> ApiPrincipal: def principal(
tenant_id: str = "tenant-1",
*,
account_id: str = "account-1",
admin: bool = True,
) -> ApiPrincipal:
scopes = {
"templates:template:read",
"templates:template:write",
"templates:template:publish",
"templates:template:render",
"files:file:upload",
}
if admin:
scopes.add("templates:template:admin")
return ApiPrincipal( return ApiPrincipal(
principal=PrincipalRef( principal=PrincipalRef(
account_id="account-1", account_id=account_id,
membership_id="membership-1", membership_id="membership-1",
tenant_id=tenant_id, tenant_id=tenant_id,
identity_id="identity-1", identity_id="identity-1",
scopes=frozenset( scopes=frozenset(scopes),
{
"templates:template:read",
"templates:template:write",
"templates:template:publish",
"templates:template:render",
"templates:template:admin",
"files:file:upload",
}
),
), ),
account=object(), account=object(),
user=type("User", (), {"id": "user-1"})(), user=type("User", (), {"id": "user-1"})(),
@@ -188,6 +198,45 @@ class TemplateServiceTests(unittest.TestCase):
self.assertIn(b"Ada", first.payload) self.assertIn(b"Ada", first.payload)
self.assertIn(b"Grace", first.payload) self.assertIn(b"Grace", first.payload)
def test_bounded_render_history_and_payload_are_owner_scoped(self) -> None:
owner = principal(admin=False)
other = principal(account_id="account-2", admin=False)
administrator = principal(account_id="account-admin")
with self.database.session() as session:
item, _ = create_template(session, owner, payload())
result = render_template(
session,
owner,
registry=_Registry(),
request=TemplateRenderRequest(
template_id=item.id,
usage="campaign.postal",
items=(
{
"name": "Ada",
"postal": {"address": "Street 1"},
},
),
),
)
session.commit()
self.assertEqual(
result.render_id,
get_render_for_principal(session, owner, result.render_id).id,
)
with self.assertRaisesRegex(TemplateRenderError, "not found"):
get_render_for_principal(session, other, result.render_id)
self.assertEqual([], list_renders(session, other))
self.assertEqual(
result.render_id,
get_render_for_principal(
session,
administrator,
result.render_id,
).id,
)
def test_label_sheet_page_count_and_missing_fields(self) -> None: def test_label_sheet_page_count_and_missing_fields(self) -> None:
with self.database.session() as session: with self.database.session() as session:
item, _ = create_template( item, _ = create_template(