4 Commits
Author SHA1 Message Date
zemion 33de5cac55 fix(packaging): expose immutable WebUI Git package for v0.1.26
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:09 +02:00
zemion a54dae9536 Release govoplan-connectors v0.1.26: bound spreadsheet traversal 2026-09-08 01:32:27 +02:00
zemion 65609665a2 feat(connectors): document consequential governance actions
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:36 +02:00
zemion a51d3781a9 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:32 +02:00
11 changed files with 289 additions and 15 deletions
+18
View File
@@ -117,3 +117,21 @@ See:
- [Governed connector configuration](docs/GOVERNED_CONNECTOR_CONFIGURATION.md)
- [MediaWiki and BlueSpice connector](docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md)
- [Znuny and OTRS-compatible service-desk connector](docs/ZNUNY_OTRS_CONNECTOR.md)
## Git-source WebUI package
The repository root exposes `@govoplan/connectors-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/connectors-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@govoplan/connectors-webui",
"version": "0.1.26",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/connectors.css": "./webui/src/styles/connectors.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-connectors"
version = "0.1.23"
version = "0.1.26"
description = "Governed connector catalogue and tabular source capabilities for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
@@ -39,7 +39,9 @@ _TRANSLATIONS = {
"title": "Gesteuerte tabellarische Quellen",
"summary": "Anbieterneutrale Quellenerkennung und begrenzte Lesezugriffe für Dataflow bereitstellen.",
"body": (
"Connectors verantwortet Quellkonfiguration, Zugriffsprüfung, Schemaerkennung, Fingerabdrücke und begrenzte Lesevorgänge. Dataflow speichert nur undurchsichtige Quellreferenzen und erwartete Fingerabdrücke. Jede Quelle weist ihren Live-, Cache-, Datei- oder statischen Modus, einen strukturierten Zustand und unterstützte Projektion, Filterung, Aggregation, Sortierung und Seitennavigation aus. Unveränderliche JSON- und CSV-Snapshots bleiben verfügbar. Verwaltete CSV- und XLSX-Quellen nutzen optional Files, binden eine exakt autorisierte Version, erzwingen Archiv- und Entpackgrenzen und übernehmen neuere Versionen erst nach ausdrücklicher Aktualisierung. Der PostgreSQL-Adapter nutzt eine aktive gesteuerte Konfiguration und eine eingegrenzte Core-Zugangsdatenhülle, liest nur einfache Schema- und Tabellenkennungen und blockiert bei Konfigurations-, Zugangsdaten- oder Schemadrift bis zur geprüften Aktualisierung. Zugangsdaten, Endpunkte, Speicherschlüssel und interne Dateiinhalte werden nie über die Quelle offengelegt."
"Connectors verantwortet Quellkonfiguration, Zugriffsprüfung, Schemaerkennung, Fingerabdrücke und begrenzte Lesevorgänge. Dataflow speichert nur undurchsichtige Quellreferenzen und erwartete Fingerabdrücke. Jede Quelle weist ihren Live-, Cache-, Datei- oder statischen Modus, einen strukturierten Zustand und unterstützte Projektion, Filterung, Aggregation, Sortierung und Seitennavigation aus. Unveränderliche JSON- und CSV-Snapshots bleiben verfügbar. Verwaltete CSV- und XLSX-Quellen nutzen optional Files, binden eine exakt autorisierte Version, erzwingen Archiv- und Entpackgrenzen und übernehmen neuere Versionen erst nach ausdrücklicher Aktualisierung. "
"XLSX-Lesevorgänge prüfen die tatsächlichen Koordinaten des ausgewählten Arbeitsblatts vor dem Aufbau des Zellrasters: höchstens 500 Spalten und 10.000 Zeilenpositionen nach der Kopfzeile einschließlich leerer Zwischenräume. Unzuverlässige Dimensionsangaben vergrößern weder das Raster noch verbergen sie Zellen; übergroße oder widersprüchliche Koordinaten führen zu einem Validierungsfehler statt zu still abgeschnittenen Daten. "
"Der PostgreSQL-Adapter nutzt eine aktive gesteuerte Konfiguration und eine eingegrenzte Core-Zugangsdatenhülle, liest nur einfache Schema- und Tabellenkennungen und blockiert bei Konfigurations-, Zugangsdaten- oder Schemadrift bis zur geprüften Aktualisierung. Zugangsdaten, Endpunkte, Speicherschlüssel und interne Dateiinhalte werden nie über die Quelle offengelegt."
),
},
"connectors.sanctions-snapshots": {
@@ -0,0 +1,77 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'connectors.data-subject-requests': {'consequence_classes': {'exclude_connector_secrets': 'Gibt '
'niemals '
'Anmeldeinformationen, '
'Endpunkte, '
'externe '
'Zeilen '
'oder '
'Beweisnutzlasten '
'zurück.',
'export_operator_attribution': 'Retouren '
'minimierte '
'Connector-Aktivität '
'für '
'das '
'genaue '
'Konto.',
'retain_connector_evidence': 'Bewahrt '
'die '
'Konfiguration '
'und '
'die '
'Rechenschaftspflicht '
'für '
'externe '
'Operationen '
'bei.'}},
'connectors.governed-configuration': {'outcome': 'Das aktive Konnektorverhalten ist inspizierbar, '
'versionengebunden, testbar und überprüfbar, '
'bevor ein anbieterspezifisches Schreiben '
'erfolgt.',
'prerequisites': ['Eine Konnektordefinition wurde '
'installiert oder erstellt.',
'Anmeldematerial wird außerhalb der '
'Connector-URL gespeichert und durch eine '
'genehmigte geheime Kennung '
'referenziert.'],
'verification': 'Laden Sie die Konfiguration neu, prüfen '
'Sie geschützte Pfade und effektiven Hash, '
'führen Sie eine Simulation mit einem neuen '
'idempotency-Schlüssel aus und lösen Sie '
'alle ausstehenden Überprüfungsergebnisse.'},
'connectors.mediawiki-bluespice': {'outcome': 'Externes Wissen bleibt identitätsstabil, '
'verlustsichtbar, ACL-sicher und migrationsfähig.',
'prerequisites': ['Eine aktive verwaltete MediaWiki Action '
'API-Konfiguration existiert.',
'Namespace-Ziele und Fallback-ACLs wurden '
'überprüft.',
'Die Such- und Wiki-Module sind optional und '
'bleiben fähigkeitsgetrennt.'],
'verification': 'Entdecken Sie das Profil neu, führen Sie ein '
'Delta mit Schlüsseln aus, inspizieren Sie '
'Gesundheit und Diagnose, überprüfen Sie einen '
'erlaubten und verweigerten Suchprinzipal und '
'führen Sie einen Migrations-Dry-Run aus, '
'bevor Sie zielseitig arbeiten.'},
'connectors.znuny-otrs': {'outcome': 'Externe Tickets bleiben identitätsstabil, verlustsichtbar, '
'ACL-sicher, wiederherstellbar und semantisch getrennt von '
'GovOPlaN-Domäneneinträgen.',
'prerequisites': ['Eine aktiv gesteuerte Znuny/OTRS GenericInterface '
'REST-Konfiguration existiert.',
'Bereitstellungsdefinierte Routen, '
'Warteschlangenpartitionen, Autorität und '
'Fallback-ACLs wurden überprüft.',
'Tickets, Helpdesk, Cases und Search bleiben '
'optionale funktionsgetrennte Verbraucher.'],
'verification': 'Entdecke das Profil neu, beende einen Keyed-Full-Run, '
'führe ein Keyed-Delta aus, inspiziere die '
'Mapping-Diagnose, überprüfe einen erlaubten und '
'verweigerten Suchprinzipal und versöhne jedes '
'ergebnisunbekannte Update vor dem erneuten Versuch.'}}
+16 -2
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_connectors.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.access import (
@@ -123,7 +126,7 @@ from govoplan_connectors.backend.german_documentation import (
MODULE_ID = "connectors"
MODULE_VERSION = "0.1.23"
MODULE_VERSION = "0.1.26"
TABULAR_SOURCE_INTERFACE_VERSION = "0.1.0"
DATASOURCE_ORIGIN_INTERFACE_VERSION = "0.1.0"
SANCTIONS_SNAPSHOT_INTERFACE_VERSION = "1.0.0"
@@ -1050,6 +1053,10 @@ manifest = ModuleManifest(
order=39,
metadata={
"kind": "reference",
"help_contexts": [
"connectors.action.reject-ambiguous-result",
"connectors.action.approve-ambiguous-result",
],
"fields": [
"Direction and technical maturity",
"Configured source authority",
@@ -1108,7 +1115,9 @@ manifest = ModuleManifest(
"Immutable JSON/CSV snapshots remain available. Managed CSV/XLSX "
"sources use the optional Files capability, pin an exact authorized "
"version, apply archive and expansion limits, and require explicit "
"refresh before adopting a newer version. The PostgreSQL adapter uses "
"refresh before adopting a newer version. XLSX reads validate actual selected-worksheet coordinates before grid allocation: "
"at most 500 columns and 10,000 row positions after the header, including blank gaps. Unreliable declared dimensions "
"neither expand the grid nor conceal cells; oversized or inconsistent coordinates fail validation instead of truncating data. The PostgreSQL adapter uses "
"an active governed configuration and scoped Core credential envelope, "
"reflects simple schema/table identifiers, runs read-only bounded "
"projection and pagination, and blocks configuration, credential, or "
@@ -1260,6 +1269,11 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -12,7 +12,10 @@ from decimal import Decimal
from io import BytesIO
from typing import Any
from defusedxml import ElementTree as SafeET
from defusedxml.common import DefusedXmlException
from openpyxl import load_workbook
from openpyxl.utils.cell import column_index_from_string
from sqlalchemy import (
JSON,
BigInteger,
@@ -595,7 +598,12 @@ def _parse_xlsx(
f"Managed XLSX worksheet {selected_name!r} was not found."
)
worksheet = workbook[selected_name]
iterator = worksheet.iter_rows(values_only=True)
maximum_column = _validate_xlsx_worksheet(worksheet)
# Declared dimensions are not authoritative: they can inflate sparse
# rows/columns or conceal actual cells. Validate the XML coordinates
# before openpyxl synthesizes any missing cells, then ignore dimensions.
worksheet.reset_dimensions()
iterator = worksheet.iter_rows(max_col=maximum_column, values_only=True)
try:
raw_headers = next(iterator)
except StopIteration as exc:
@@ -604,10 +612,10 @@ def _parse_xlsx(
) from exc
headers = _xlsx_headers(raw_headers)
rows: list[Mapping[str, object]] = []
for values in iterator:
if len(rows) >= MAX_FILE_ROWS:
for row_number, values in enumerate(iterator, start=1):
if row_number > MAX_FILE_ROWS:
raise TabularSourceValidationError(
f"Managed XLSX worksheets are limited to {MAX_FILE_ROWS:,} data rows."
f"Managed XLSX worksheets are limited to {MAX_FILE_ROWS:,} row positions after the header, including blank gaps."
)
normalized = tuple(values[: len(headers)])
if all(value in (None, "") for value in normalized):
@@ -625,6 +633,60 @@ def _parse_xlsx(
workbook.close()
def _validate_xlsx_worksheet(worksheet: Any) -> int:
"""Bound actual coordinates before read-only openpyxl allocates row tuples.
The pinned openpyxl 3.x read-only source handle is streamed and closed here;
no untrusted dimensions or filesystem paths are used to allocate a grid.
"""
maximum_column = 1
row_position = 0
rows_seen = 0
column_position = 0
cells_seen = 0
try:
with worksheet._get_source() as source:
for event, element in SafeET.iterparse(source, events=("start", "end"), forbid_dtd=True):
tag = element.tag.rsplit("}", 1)[-1]
if event == "end":
element.clear()
continue
if tag == "row":
rows_seen += 1
raw_row = element.get("r", str(row_position + 1))
if len(raw_row) > 7 or not raw_row.isascii() or not raw_row.isdigit():
raise TabularSourceValidationError("Managed XLSX content has an invalid row coordinate.")
row_position = int(raw_row)
if not 1 <= row_position <= MAX_FILE_ROWS + 1 or rows_seen > MAX_FILE_ROWS + 1:
raise TabularSourceValidationError(
f"Managed XLSX worksheets are limited to {MAX_FILE_ROWS:,} row positions after the header, including blank gaps."
)
column_position = 0
cells_seen = 0
elif tag == "c":
cells_seen += 1
reference = element.get("r")
if reference is not None:
match = re.fullmatch(r"([A-Za-z]{1,3})([1-9][0-9]{0,6})", reference)
if match is None:
raise TabularSourceValidationError("Managed XLSX content has an invalid cell coordinate.")
column_position = column_index_from_string(match.group(1))
if int(match.group(2)) != row_position:
raise TabularSourceValidationError("Managed XLSX cell and row coordinates do not agree.")
else:
column_position += 1
if column_position > MAX_FILE_COLUMNS or cells_seen > MAX_FILE_COLUMNS:
raise TabularSourceValidationError(
f"Managed XLSX worksheets are limited to {MAX_FILE_COLUMNS:,} columns."
)
maximum_column = max(maximum_column, column_position)
return maximum_column
except (DefusedXmlException, SafeET.ParseError, ValueError) as exc:
if isinstance(exc, TabularSourceValidationError):
raise
raise TabularSourceValidationError("Managed XLSX worksheet XML could not be safely parsed.") from exc
def _validate_xlsx_archive(payload: bytes) -> None:
try:
with zipfile.ZipFile(BytesIO(payload)) as archive:
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import re
import unittest
from io import BytesIO
from unittest.mock import patch
from zipfile import ZipFile
from openpyxl import Workbook
from govoplan_core.core.tabular_sources import TabularSourceValidationError
from govoplan_connectors.backend import tabular_adapters as adapters
def workbook_bytes(*, dimension: str | None = None, last_cell: str = "A2") -> bytes:
workbook = Workbook()
workbook.active.append(["name"])
workbook.active.append(["Ada"])
output = BytesIO()
workbook.save(output)
workbook.close()
result = BytesIO()
with ZipFile(BytesIO(output.getvalue())) as original, ZipFile(result, "w") as modified:
for entry in original.infolist():
content = original.read(entry)
if entry.filename == "xl/worksheets/sheet1.xml":
xml = content.decode()
if dimension is not None:
xml = re.sub(r'<dimension ref="[^"]+"\s*/>', f'<dimension ref="{dimension}"/>', xml)
xml = xml.replace('r="A2"', f'r="{last_cell}"')
xml = xml.replace('<row r="2">', f'<row r="{re.search(r"[0-9]+$", last_cell).group()}">')
content = xml.encode()
modified.writestr(entry, content)
return result.getvalue()
def parse(payload: bytes):
return adapters.parse_managed_tabular_content(payload, filename="fixture.xlsx", content_type=None, delimiter=",", sheet_name=None)
class XlsxSafetyBoundsTests(unittest.TestCase):
def test_sparse_rows_cannot_bypass_limit_by_not_counting_as_data(self):
with patch.object(adapters, "MAX_FILE_ROWS", 3):
with self.assertRaisesRegex(TabularSourceValidationError, "row"):
parse(workbook_bytes(last_cell="A6"))
def test_forged_small_dimensions_cannot_hide_far_away_cells(self):
with patch.object(adapters, "MAX_FILE_ROWS", 3):
with self.assertRaisesRegex(TabularSourceValidationError, "row"):
parse(workbook_bytes(dimension="A1:A2", last_cell="A1000000"))
def test_forged_small_dimensions_cannot_hide_out_of_range_columns(self):
with self.assertRaisesRegex(TabularSourceValidationError, "column"):
parse(workbook_bytes(dimension="A1:A2", last_cell="XFD2"))
def test_declared_dimensions_do_not_expand_or_truncate_real_rows(self):
for dimension in ("A1:XFD1048576", "A1:A1"):
with self.subTest(dimension=dimension):
rows, sheet = parse(workbook_bytes(dimension=dimension))
self.assertEqual(({"name": "Ada"},), rows)
self.assertEqual("Sheet", sheet)
def test_small_blank_gaps_and_exact_limit_remain_usable(self):
with patch.object(adapters, "MAX_FILE_ROWS", 3):
rows, _sheet = parse(workbook_bytes(last_cell="A4"))
self.assertEqual(({"name": "Ada"},), rows)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/connectors-webui",
"version": "0.1.23",
"version": "0.1.26",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -483,7 +483,7 @@ export default function ConnectorGovernancePage({ settings, auth }: Props) {
<FormField label="Endpoint URL" hint="Credentials are rejected in URLs.">
<input value={draft.endpoint_url} disabled={!canAdmin || busy} placeholder="https://provider.example/api" onChange={(event) => setDraft({ ...draft, endpoint_url: event.target.value })} />
</FormField>
<FormField label="Credential reference" hint="Reference an approved secret; do not paste a secret.">
<FormField label="Credential reference" hint="Reference an approved secret; do not paste a secret." helpContextId="connectors.admin.governed-configurations" helpModuleId="connectors">
<input value={draft.credential_ref} disabled={!canAdmin || busy} placeholder="vault://connectors/provider" onChange={(event) => setDraft({ ...draft, credential_ref: event.target.value })} />
</FormField>
<FormField label="Ambiguous-result policy">
@@ -574,7 +574,7 @@ export default function ConnectorGovernancePage({ settings, auth }: Props) {
<FormField label="Endpoint URL">
<input value={newDraft.endpoint_url} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, endpoint_url: event.target.value })} />
</FormField>
<FormField label="Credential reference">
<FormField label="Credential reference" helpContextId="connectors.admin.governed-configurations" helpModuleId="connectors">
<input value={newDraft.credential_ref} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, credential_ref: event.target.value })} />
</FormField>
<FormField label="Ambiguous-result policy">
@@ -597,8 +597,8 @@ export default function ConnectorGovernancePage({ settings, auth }: Props) {
closeDisabled={busy}
footer={<>
<Button onClick={() => setReviewRun(null)} disabled={busy}>Cancel</Button>
<Button variant="danger" onClick={() => void decideReview("rejected")} disabled={busy || reviewReason.trim().length < 5}>Reject</Button>
<Button variant="primary" onClick={() => void decideReview("approved")} disabled={busy || reviewReason.trim().length < 5}>Approve</Button>
<Button variant="danger" helpContextId="connectors.action.reject-ambiguous-result" helpModuleId="connectors" onClick={() => void decideReview("rejected")} disabled={busy || reviewReason.trim().length < 5}>Reject</Button>
<Button variant="primary" helpContextId="connectors.action.approve-ambiguous-result" helpModuleId="connectors" onClick={() => void decideReview("approved")} disabled={busy || reviewReason.trim().length < 5}>Approve</Button>
</>}
>
<p>Review {reviewRun?.summary.ambiguous ?? 0} ambiguous effects against the retained input and configuration hashes before deciding.</p>
+2 -2
View File
@@ -326,7 +326,7 @@ export default function ExternalKnowledgePage({ settings, auth }: Props) {
<Button variant="secondary" onClick={() => void sync(false)} disabled={!selected || !canSync || busy || dirty}>Run delta</Button>
<Button variant="secondary" onClick={() => void sync(true)} disabled={!selected || !canSync || busy || dirty}>Run full backfill</Button>
<Button variant="secondary" onClick={() => setMigrationOpen(true)} disabled={!selected || !canMigrate || busy || dirty}>Preview migration</Button>
<Button variant="primary" onClick={() => setPublishOpen(true)} disabled={!selected || !canPublish || busy || dirty}>Publish page</Button>
<Button variant="primary" helpContextId="connectors.admin.external-knowledge" helpModuleId="connectors" onClick={() => setPublishOpen(true)} disabled={!selected || !canPublish || busy || dirty}>Publish page</Button>
</>}
discardAction={{
label: "Discard changes",
@@ -501,7 +501,7 @@ export default function ExternalKnowledgePage({ settings, auth }: Props) {
<Dialog open={publishOpen} title="Publish provider page revision" onClose={() => !busy && setPublishOpen(false)} closeDisabled={busy} footer={<>
<Button onClick={() => setPublishOpen(false)} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void publish()} disabled={busy || !externalPageId.trim() || !publishTitle.trim()}>Publish revision</Button>
<Button variant="primary" helpContextId="connectors.admin.external-knowledge" helpModuleId="connectors" onClick={() => void publish()} disabled={busy || !externalPageId.trim() || !publishTitle.trim()}>Publish revision</Button>
</>}>
<p className="muted">Publication is an external effect. Supply the current provider revision where possible; an unknown outcome blocks blind retry.</p>
<FormGrid columns={2} collapseAt="standard" className="">