feat(connectors): govern RSS and Atom publication
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-21 17:36:19 +02:00
parent de8e74b866
commit e6ab8291ec
9 changed files with 182 additions and 18 deletions
+9 -1
View File
@@ -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",
]
+27 -5
View File
@@ -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
+42 -4
View File
@@ -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),
+15 -4
View File
@@ -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):