feat(connectors): govern RSS and Atom publication
Module Package Release / publish-packages (push) Successful in 11s
Module Package Release / publish-packages (push) Successful in 11s
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
+78
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user