diff --git a/README.md b/README.md index 34579d4..97400b2 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ recovery contract requires stable idempotency, provider verification, and operator reconciliation, but no connector currently claims a production write or delete path. +RSS and Atom emission is a bounded renderer, not an authority shortcut. Every +selected entry declares whether it came from a GovOPlaN event, publication, +case, or report and carries an opaque owning-module reference and optional +revision. Public-feed permission can render only public entries. Tenant and +private audiences require a separate restricted-feed permission, and the API +derives the allowed visibility set from that audience instead of accepting a +caller-controlled allow-list. Portal or Reporting remains responsible for any +durable public or authenticated route and must re-authorize restricted access. + The governed connector runtime adds immutable definition revisions, revision-pinned tenant configurations, protected local overrides, explicit package-update adoption, bounded dry-runs and simulations, redacted provenance, diff --git a/pyproject.toml b/pyproject.toml index c9ea66c..e552511 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-connectors" -version = "0.1.18" +version = "0.1.19" description = "Governed connector catalogue and tabular source capabilities for GovOPlaN." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_connectors/backend/feeds.py b/src/govoplan_connectors/backend/feeds.py index 1711983..df5b250 100644 --- a/src/govoplan_connectors/backend/feeds.py +++ b/src/govoplan_connectors/backend/feeds.py @@ -24,6 +24,8 @@ from govoplan_core.security.http_fetch import fetch_http MAX_FEED_BYTES = 5_000_000 ATOM_NS = "http://www.w3.org/2005/Atom" +FEED_PUBLISH_SCOPE = "connectors:feeds:publish" +FEED_PRIVATE_PUBLISH_SCOPE = "connectors:feeds:publish_private" class ConnectorFeedProvider(FeedProvider): @@ -410,4 +412,10 @@ def _element( return child -__all__ = ["ConnectorFeedProvider", "MAX_FEED_BYTES", "feed_rows"] +__all__ = [ + "ConnectorFeedProvider", + "FEED_PRIVATE_PUBLISH_SCOPE", + "FEED_PUBLISH_SCOPE", + "MAX_FEED_BYTES", + "feed_rows", +] diff --git a/src/govoplan_connectors/backend/manifest.py b/src/govoplan_connectors/backend/manifest.py index fe0b206..d5d8ac4 100644 --- a/src/govoplan_connectors/backend/manifest.py +++ b/src/govoplan_connectors/backend/manifest.py @@ -67,7 +67,11 @@ from govoplan_connectors.backend.tabular_sources import ( from govoplan_connectors.backend.datasource_origins import ( ConnectorDatasourceOriginProvider, ) -from govoplan_connectors.backend.feeds import ConnectorFeedProvider +from govoplan_connectors.backend.feeds import ( + FEED_PRIVATE_PUBLISH_SCOPE, + FEED_PUBLISH_SCOPE, + ConnectorFeedProvider, +) from govoplan_connectors.backend.provider_state import ( SANCTIONS_PROVIDER_ID, TABULAR_PROVIDER_ID, @@ -77,11 +81,11 @@ from govoplan_connectors.backend.provider_state import ( MODULE_ID = "connectors" -MODULE_VERSION = "0.1.18" +MODULE_VERSION = "0.1.19" TABULAR_SOURCE_INTERFACE_VERSION = "0.1.0" DATASOURCE_ORIGIN_INTERFACE_VERSION = "0.1.0" SANCTIONS_SNAPSHOT_INTERFACE_VERSION = "1.0.0" -FEED_INTERFACE_VERSION = "0.1.0" +FEED_INTERFACE_VERSION = "0.2.0" CONNECTOR_RUNTIME_INTERFACE_VERSION = "1.0.0" ARCHITECTURE = ModuleArchitectureDeclaration( @@ -278,6 +282,16 @@ PERMISSIONS = ( "Administer connector sources", "Manage tenant connector sources, versioned definitions, protected overrides, and review policies.", ), + _permission( + FEED_PUBLISH_SCOPE, + "Publish public feeds", + "Render provenance-bearing public RSS or Atom output from selected GovOPlaN objects.", + ), + _permission( + FEED_PRIVATE_PUBLISH_SCOPE, + "Publish restricted feeds", + "Render tenant or private RSS or Atom output after the owning product surface has authorized every selected object.", + ), _permission( SANCTIONS_READ_SCOPE, "View sanctions source evidence", @@ -299,6 +313,8 @@ ROLE_TEMPLATES = ( READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE, + FEED_PUBLISH_SCOPE, + FEED_PRIVATE_PUBLISH_SCOPE, SANCTIONS_READ_SCOPE, SANCTIONS_REFRESH_SCOPE, ), @@ -310,6 +326,7 @@ ROLE_TEMPLATES = ( permissions=( READ_SCOPE, WRITE_SCOPE, + FEED_PUBLISH_SCOPE, SANCTIONS_READ_SCOPE, SANCTIONS_REFRESH_SCOPE, ), @@ -649,8 +666,13 @@ manifest = ModuleManifest( "Connectors owns bounded, SSRF-protected RSS/Atom transport and XML " "parsing. Imported entries become immutable tabular snapshots exposed " "through Datasources, including acquisition, freshness, ETag, content " - "digest, and source provenance. Portal or Reporting owns publication " - "routes and must pass the allowed visibility set when rendering output. " + "digest, and source provenance. Emission accepts only provenance-bearing " + "event, publication, case, or report selections from an owning surface. " + "The requested audience determines the visibility ceiling: public output " + "contains only public entries, while tenant or private output requires a " + "separate restricted-feed permission. Callers cannot supply their own " + "visibility allow-list. Portal or Reporting owns durable publication " + "routes and must re-authorize access on every restricted feed request. " "A separate RSS module is only warranted if GovOPlaN later needs a " "dedicated feed-reader product surface." ), diff --git a/src/govoplan_connectors/backend/provider_state.py b/src/govoplan_connectors/backend/provider_state.py index dfe1134..3d11c23 100644 --- a/src/govoplan_connectors/backend/provider_state.py +++ b/src/govoplan_connectors/backend/provider_state.py @@ -1,6 +1,5 @@ from __future__ import annotations -from collections import defaultdict from datetime import UTC, datetime from hashlib import sha256 diff --git a/src/govoplan_connectors/backend/router.py b/src/govoplan_connectors/backend/router.py index 1edec12..55fb6f8 100644 --- a/src/govoplan_connectors/backend/router.py +++ b/src/govoplan_connectors/backend/router.py @@ -22,6 +22,7 @@ from govoplan_core.core.feeds import ( FeedCapabilityError, FeedEntry, FeedRenderRequest, + FeedVisibility, ) from govoplan_core.core.sanctions import SanctionsSnapshotReference from govoplan_core.db.session import get_session @@ -29,6 +30,7 @@ from govoplan_connectors.backend.schemas import ( FeedAcquireRequest, FeedDocumentResponse, FeedImportRequest, + FeedPublicationEntryPayload, FeedRenderPayload, SanctionsAcquisitionRunListResponse, SanctionsAcquisitionRunResponse, @@ -47,7 +49,12 @@ from govoplan_connectors.backend.schemas import ( TabularSourcePreviewResponse, TabularSourceResponse, ) -from govoplan_connectors.backend.feeds import ConnectorFeedProvider, feed_rows +from govoplan_connectors.backend.feeds import ( + FEED_PRIVATE_PUBLISH_SCOPE, + FEED_PUBLISH_SCOPE, + ConnectorFeedProvider, + feed_rows, +) from govoplan_connectors.backend.governed_runtime import ( GovernedConnectorError, create_configuration, @@ -328,7 +335,9 @@ def api_render_feed( payload: FeedRenderPayload, principal: ApiPrincipal = Depends(get_api_principal), ) -> Response: - _require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE) + _require_any_scope(principal, FEED_PUBLISH_SCOPE, ADMIN_SCOPE) + if payload.audience != "public": + _require_any_scope(principal, FEED_PRIVATE_PUBLISH_SCOPE, ADMIN_SCOPE) try: rendered = feed_transport.render( FeedRenderRequest( @@ -339,9 +348,9 @@ def api_render_feed( description=payload.description, language=payload.language, entries=tuple( - FeedEntry(**item.model_dump()) for item in payload.entries + _publication_feed_entry(item) for item in payload.entries ), - allowed_visibilities=frozenset(payload.allowed_visibilities), + allowed_visibilities=_feed_audience_visibilities(payload.audience), ) ) except FeedCapabilityError as exc: @@ -352,10 +361,39 @@ def api_render_feed( headers={ "X-GovOPlaN-Feed-Included": str(rendered.included_entries), "X-GovOPlaN-Feed-Excluded": str(rendered.excluded_entries), + "X-GovOPlaN-Feed-Audience": payload.audience, }, ) +def _feed_audience_visibilities( + audience: str, +) -> frozenset[FeedVisibility]: + if audience == "public": + return frozenset({"public"}) + if audience == "tenant": + return frozenset({"public", "tenant"}) + return frozenset({"public", "tenant", "private"}) + + +def _publication_feed_entry(item: FeedPublicationEntryPayload) -> FeedEntry: + values = item.model_dump( + exclude={"source_kind", "source_module", "source_ref", "source_revision"} + ) + values["categories"] = tuple(values["categories"]) + values["enclosures"] = tuple(values["enclosures"]) + values["metadata"] = { + **values["metadata"], + "govoplan_source": { + "kind": item.source_kind, + "module": item.source_module, + "reference": item.source_ref, + "revision": item.source_revision, + }, + } + return FeedEntry(**values) + + @router.get("/tabular-sources", response_model=TabularSourceListResponse) def api_list_tabular_sources( query: str = Query(default="", max_length=200), diff --git a/src/govoplan_connectors/backend/schemas.py b/src/govoplan_connectors/backend/schemas.py index 6de5947..3c2d48e 100644 --- a/src/govoplan_connectors/backend/schemas.py +++ b/src/govoplan_connectors/backend/schemas.py @@ -36,6 +36,17 @@ class FeedEntryPayload(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) +class FeedPublicationEntryPayload(FeedEntryPayload): + source_kind: Literal["event", "publication", "case", "report"] + source_module: str = Field( + min_length=1, + max_length=100, + pattern=r"^[a-z][a-z0-9_]*$", + ) + source_ref: str = Field(min_length=1, max_length=500) + source_revision: str | None = Field(default=None, max_length=200) + + class FeedDocumentResponse(BaseModel): format: Literal["rss", "atom"] title: str @@ -61,11 +72,11 @@ class FeedRenderPayload(BaseModel): home_url: str = Field(min_length=1, max_length=2000) description: str | None = None language: str | None = Field(default=None, max_length=100) - entries: list[FeedEntryPayload] = Field(default_factory=list, max_length=10_000) - allowed_visibilities: list[Literal["public", "tenant", "private"]] = Field( - default_factory=lambda: ["public"], - max_length=3, + entries: list[FeedPublicationEntryPayload] = Field( + default_factory=list, + max_length=10_000, ) + audience: Literal["public", "tenant", "private"] = "public" class SnapshotCreateRequest(BaseModel): diff --git a/tests/test_feeds.py b/tests/test_feeds.py index d04ee3e..740eeef 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -4,8 +4,18 @@ import unittest from unittest.mock import patch from defusedxml import ElementTree as SafeET +from fastapi import HTTPException -from govoplan_connectors.backend.feeds import ConnectorFeedProvider, feed_rows +from govoplan_connectors.backend.feeds import ( + FEED_PRIVATE_PUBLISH_SCOPE, + FEED_PUBLISH_SCOPE, + ConnectorFeedProvider, + feed_rows, +) +from govoplan_connectors.backend.router import api_render_feed +from govoplan_connectors.backend.schemas import FeedRenderPayload +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef from govoplan_core.core.feeds import ( FeedCapabilityError, FeedEntry, @@ -98,6 +108,73 @@ class ConnectorFeedProviderTests(unittest.TestCase): with self.assertRaisesRegex(FeedCapabilityError, "not safe or valid"): self.provider.parse(payload, source_url="https://example.test/rss") + def test_render_api_derives_visibility_from_audience_and_permissions(self) -> None: + entries = [ + { + "id": visibility, + "title": visibility.title(), + "visibility": visibility, + "source_kind": source_kind, + "source_module": source_module, + "source_ref": f"{source_kind}:{visibility}", + "source_revision": "7", + } + for visibility, source_kind, source_module in ( + ("public", "publication", "docs"), + ("tenant", "case", "cases"), + ("private", "report", "reporting"), + ) + ] + public_payload = FeedRenderPayload( + format="rss", + title="Selected GovOPlaN updates", + feed_url="https://example.test/feed.xml", + home_url="https://example.test/", + audience="public", + entries=entries, + ) + public = api_render_feed( + public_payload, + principal=_principal(FEED_PUBLISH_SCOPE), + ) + + self.assertEqual("public", public.headers["x-govoplan-feed-audience"]) + self.assertEqual("1", public.headers["x-govoplan-feed-included"]) + self.assertEqual("2", public.headers["x-govoplan-feed-excluded"]) + self.assertIn(b"Public", public.body) + self.assertNotIn(b"Tenant", public.body) + + restricted_payload = public_payload.model_copy(update={"audience": "private"}) + with self.assertRaises(HTTPException) as denied: + api_render_feed( + restricted_payload, + principal=_principal(FEED_PUBLISH_SCOPE), + ) + self.assertEqual(403, denied.exception.status_code) + + restricted = api_render_feed( + restricted_payload, + principal=_principal( + FEED_PUBLISH_SCOPE, + FEED_PRIVATE_PUBLISH_SCOPE, + ), + ) + self.assertEqual("3", restricted.headers["x-govoplan-feed-included"]) + self.assertIn(b"Private", restricted.body) + + +def _principal(*scopes: str) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=object(), + user=object(), + ) + if __name__ == "__main__": unittest.main() diff --git a/webui/package.json b/webui/package.json index 01a3996..613c5f7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/connectors-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts",