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,
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user