diff --git a/README.md b/README.md index db33287..5ca241c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md index b2d0c04..b35319f 100644 --- a/docs/ADMIN_GUIDE.md +++ b/docs/ADMIN_GUIDE.md @@ -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 diff --git a/docs/TEMPLATE_BOUNDARY.md b/docs/TEMPLATE_BOUNDARY.md index 82cce14..0f928ac 100644 --- a/docs/TEMPLATE_BOUNDARY.md +++ b/docs/TEMPLATE_BOUNDARY.md @@ -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. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c195315..24cf61e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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; diff --git a/src/govoplan_templates/backend/rendering.py b/src/govoplan_templates/backend/rendering.py index dc1f325..ff712cb 100644 --- a/src/govoplan_templates/backend/rendering.py +++ b/src/govoplan_templates/backend/rendering.py @@ -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) diff --git a/tests/test_templates.py b/tests/test_templates.py index 42d4a73..0a8b716 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -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(