Restrict rendered template artifacts
This commit is contained in:
@@ -18,7 +18,7 @@ The first operational slice provides:
|
||||
- immutable template/input/output hashes, renderer version, item/page counts,
|
||||
diagnostics, and idempotent final-output evidence;
|
||||
- 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.
|
||||
|
||||
The module has no hard dependency on its consumers or on Files.
|
||||
|
||||
@@ -24,6 +24,12 @@ template, input, and output hashes. Otherwise Templates stores a bounded
|
||||
database payload. Review database and Files retention together before deleting
|
||||
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
|
||||
|
||||
Apply the module Alembic migration before startup. Monitor rejected renders for
|
||||
|
||||
@@ -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
|
||||
managed persistence through that contract. If Files is absent, incompatible,
|
||||
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.
|
||||
|
||||
## Safety And Determinism
|
||||
@@ -50,6 +50,8 @@ Templates payload column.
|
||||
|
||||
Template definitions, revisions, render evidence, and bounded output are in the
|
||||
shared database and therefore follow platform backup and restore. Managed Files
|
||||
artifacts follow Files recovery. Retiring the module is destructive only after
|
||||
the installer captures a database snapshot; consumers retain pinned hashes and
|
||||
must diagnose the now-unavailable provider.
|
||||
artifacts follow Files recovery. Bounded render payloads and history are visible
|
||||
only to their creator or a Templates administrator; consumers provide a
|
||||
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.
|
||||
|
||||
@@ -14,6 +14,9 @@ Open **Templates** to create or select a reusable definition.
|
||||
mismatches before output is produced.
|
||||
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.
|
||||
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
|
||||
output hashes. A consumer such as Campaign can submit many frozen recipients;
|
||||
|
||||
@@ -31,6 +31,7 @@ from govoplan_templates.backend.db.models import (
|
||||
TemplateRevision,
|
||||
)
|
||||
from govoplan_templates.backend.service import (
|
||||
ADMIN_SCOPE,
|
||||
RENDER_SCOPE,
|
||||
compatibility,
|
||||
get_template,
|
||||
@@ -199,6 +200,14 @@ def get_render_for_principal(
|
||||
if row is None:
|
||||
raise TemplateRenderError("Template render not found.")
|
||||
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
|
||||
|
||||
|
||||
@@ -212,6 +221,10 @@ def list_renders(
|
||||
statement = select(TemplateRender).where(
|
||||
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:
|
||||
get_template(session, principal, template_id)
|
||||
statement = statement.where(TemplateRender.template_id == template_id)
|
||||
|
||||
+62
-13
@@ -10,6 +10,7 @@ from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
TemplateCompatibilityError,
|
||||
TemplateRenderError,
|
||||
TemplateRenderRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -20,7 +21,11 @@ from govoplan_templates.backend.db.models import (
|
||||
TemplateRender,
|
||||
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 (
|
||||
TemplateCreateRequest,
|
||||
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(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
account_id=account_id,
|
||||
membership_id="membership-1",
|
||||
tenant_id=tenant_id,
|
||||
identity_id="identity-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"templates:template:read",
|
||||
"templates:template:write",
|
||||
"templates:template:publish",
|
||||
"templates:template:render",
|
||||
"templates:template:admin",
|
||||
"files:file:upload",
|
||||
}
|
||||
),
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=object(),
|
||||
user=type("User", (), {"id": "user-1"})(),
|
||||
@@ -188,6 +198,45 @@ class TemplateServiceTests(unittest.TestCase):
|
||||
self.assertIn(b"Ada", 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:
|
||||
with self.database.session() as session:
|
||||
item, _ = create_template(
|
||||
|
||||
Reference in New Issue
Block a user