fix(security): isolate reference execution and bound source inputs
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.security.bounded_process import ProcessBudgetError
|
||||
from govoplan_dataflow.backend.backends import (
|
||||
BackendExecutionError, BackendSource, ExecutionBudget, execute_typed_graph,
|
||||
)
|
||||
from govoplan_dataflow.backend.backends.reference import ReferenceExecutionBackend
|
||||
from govoplan_dataflow.backend.batches import TypedBatch
|
||||
from govoplan_dataflow.backend.executor import PipelineExecutionError, ResolvedSource
|
||||
from govoplan_dataflow.backend.manifest import get_manifest
|
||||
from govoplan_dataflow.backend.schemas import GraphEdge, GraphNode, GraphPosition, PipelineGraph
|
||||
from govoplan_dataflow.backend.service import _execute_pipeline_preview, _typed_backend_sources
|
||||
|
||||
|
||||
def graph_for(expression: str = "value", rows: list | None = None) -> PipelineGraph:
|
||||
nodes = [
|
||||
GraphNode(id="source", type="source.inline", label="Source", position=GraphPosition(x=0, y=0),
|
||||
config={"source_name": "records", "rows": rows or [{"value": "normal"}]}),
|
||||
GraphNode(id="expression", type="expression", label="Expression", position=GraphPosition(x=100, y=0),
|
||||
config={"target_column": "result", "expression": expression, "result_type": "unknown"}),
|
||||
GraphNode(id="output", type="output", label="Output", position=GraphPosition(x=200, y=0), config={}),
|
||||
]
|
||||
return PipelineGraph(nodes=nodes, edges=[
|
||||
GraphEdge(id="first", source="source", target="expression"),
|
||||
GraphEdge(id="second", source="expression", target="output"),
|
||||
])
|
||||
|
||||
|
||||
class ReferenceProcessTests(unittest.TestCase):
|
||||
def test_non_finite_deadlines_are_rejected_before_registry_wait(self) -> None:
|
||||
for value in (float("nan"), float("inf")):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "finite"):
|
||||
ExecutionBudget(max_wall_seconds=value)
|
||||
|
||||
def test_real_child_not_parent_helper_and_typed_result_preserved(self) -> None:
|
||||
row = {"value": Decimal("1.20"), "date": date(2026, 9, 8),
|
||||
"when": datetime(2026, 9, 8, tzinfo=timezone.utc), "binary": b"\x00\xff"}
|
||||
graph = graph_for()
|
||||
graph.nodes[0] = graph.nodes[0].model_copy(update={"type": "source.reference", "config": {
|
||||
"source_ref": "datasource:fixture", "source_name": "records",
|
||||
}})
|
||||
source = BackendSource(node_id="source", batch=TypedBatch.from_rows([row]),
|
||||
source_ref="datasource:fixture", provider="test", fingerprint="pinned", total_rows=1)
|
||||
with patch.object(ReferenceExecutionBackend, "_execute_in_process", side_effect=AssertionError("parent evaluation")):
|
||||
result = execute_typed_graph(graph, backend="reference", sources={"source": source})
|
||||
self.assertEqual(result.rows, [{**row, "result": Decimal("1.20")}])
|
||||
self.assertEqual(result.contract.lineage.source_fingerprints[0]["fingerprint"], "pinned")
|
||||
|
||||
def test_real_pathological_regex_is_stopped_with_structured_failure(self) -> None:
|
||||
graph = graph_for("regexp_full_match(value, '(a+)+$')", [{"value": "a" * 100 + "!"}])
|
||||
started = time.monotonic()
|
||||
with self.assertRaises(BackendExecutionError) as caught:
|
||||
execute_typed_graph(graph, backend="reference", budget=ExecutionBudget(max_wall_seconds=2))
|
||||
self.assertIn(caught.exception.code, {"backend.process.timeout", "backend.process.cpu_limit"})
|
||||
self.assertLess(time.monotonic() - started, 4)
|
||||
|
||||
def test_aggregate_padding_allocation_is_contained_by_child_memory_limit(self) -> None:
|
||||
graph = graph_for("lpad(value, 900000, '0')", [{"value": "x"} for _ in range(250)])
|
||||
with self.assertRaises(BackendExecutionError) as caught:
|
||||
execute_typed_graph(graph, backend="reference", budget=ExecutionBudget(
|
||||
max_wall_seconds=5, max_memory_bytes=128 * 1024 * 1024,
|
||||
))
|
||||
self.assertEqual(caught.exception.code, "backend.process.memory_limit")
|
||||
|
||||
def test_busy_preview_is_retryable_and_never_evaluates_inline(self) -> None:
|
||||
with patch("govoplan_dataflow.backend.backends.reference.run_bounded_operation", side_effect=ProcessBudgetError("busy")):
|
||||
with self.assertRaises(PipelineExecutionError) as caught:
|
||||
_execute_pipeline_preview(graph_for(), session=None, principal=None, registry=None,
|
||||
backend="reference", row_limit=10, preview_node_id=None)
|
||||
self.assertTrue(caught.exception.retryable)
|
||||
self.assertEqual(caught.exception.diagnostics[-1].code, "backend.process.busy")
|
||||
|
||||
def test_nested_source_ids_do_not_alias_different_authorized_data(self) -> None:
|
||||
outer = graph_for()
|
||||
inner = graph_for()
|
||||
for graph, ref in ((outer, "datasource:outer"), (inner, "datasource:inner")):
|
||||
graph.nodes[0] = graph.nodes[0].model_copy(update={"type": "source.reference", "config": {
|
||||
"source_ref": ref, "source_name": "records",
|
||||
}})
|
||||
outer.nodes[1] = outer.nodes[1].model_copy(update={"type": "subflow", "config": {
|
||||
"graph": inner.model_dump(mode="python"), "parameters": {},
|
||||
}})
|
||||
def resolve(node, limit):
|
||||
return ResolvedSource(rows=({"value": node.config["source_ref"]},),
|
||||
source_ref=node.config["source_ref"], provider="test",
|
||||
fingerprint=node.config["source_ref"], total_rows=1)
|
||||
sources = _typed_backend_sources(outer, source_resolver=resolve, include_subflows=True)
|
||||
self.assertEqual({source.source_ref for source in sources.values()}, {"datasource:outer", "datasource:inner"})
|
||||
self.assertEqual(len(sources), 2)
|
||||
|
||||
def test_static_worker_documentation_is_bilingual(self) -> None:
|
||||
topic = next(item for item in get_manifest().documentation if item.id == "dataflow.reference-worker-limits")
|
||||
for body in (topic.body, topic.translations["de"]["body"]):
|
||||
self.assertIn("GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY", body)
|
||||
self.assertIn("32 MiB", body)
|
||||
self.assertEqual(set(topic.documentation_types), {"user", "admin"})
|
||||
|
||||
def test_cumulative_source_budget_stops_before_reading_further_sources(self) -> None:
|
||||
graph = graph_for()
|
||||
graph.nodes = [graph.nodes[0].model_copy(update={
|
||||
"id": f"source-{index}", "type": "source.reference", "config": {"source_ref": f"fixture:{index}"},
|
||||
}) for index in range(3)]
|
||||
calls = []
|
||||
def resolve(node, limit):
|
||||
calls.append(node.id)
|
||||
return ResolvedSource(rows=({"value": "x" * 100},), source_ref=node.config["source_ref"],
|
||||
provider="test", fingerprint="fixed", total_rows=1)
|
||||
with self.assertRaisesRegex(PipelineExecutionError, "Combined source data"):
|
||||
_typed_backend_sources(graph, source_resolver=resolve, include_subflows=True,
|
||||
_remaining_source_bytes=[200])
|
||||
self.assertEqual(calls, ["source-0", "source-1"])
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.datasources import (
|
||||
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||
DatasourceAccessError,
|
||||
DatasourceCatalogueProvider,
|
||||
DatasourceDescriptor,
|
||||
DatasourceReadRequest,
|
||||
DatasourceReadResult,
|
||||
)
|
||||
from govoplan_core.security.bounded_process import run_bounded_operation
|
||||
from govoplan_dataflow.backend.backends.reference import ReferenceExecutionBackend
|
||||
from govoplan_dataflow.backend.executor import EXECUTOR_VERSION, PipelineExecutionError
|
||||
from govoplan_dataflow.backend.graph import validate_graph
|
||||
from govoplan_dataflow.backend.schemas import GraphEdge, GraphNode, GraphPosition, PipelineGraph
|
||||
from govoplan_dataflow.backend.service import _execute_pipeline_preview
|
||||
|
||||
|
||||
OUTER_ROWS = ({"id": "outer", "amount": 15}, {"id": "outer-low", "amount": 5})
|
||||
INNER_ROWS = ({"id": "inner", "amount": 25}, {"id": "inner-low", "amount": 2})
|
||||
|
||||
|
||||
def node(node_id: str, node_type: str, config: dict) -> GraphNode:
|
||||
return GraphNode(
|
||||
id=node_id, type=node_type, label=node_id,
|
||||
position=GraphPosition(x=0, y=0), config=config,
|
||||
)
|
||||
|
||||
|
||||
def nested_source_graph() -> PipelineGraph:
|
||||
# Both external sources deliberately share their node ID and logical name.
|
||||
# The pinned subflow still has exactly one distinct inline input binding.
|
||||
nested = PipelineGraph(
|
||||
nodes=[
|
||||
node("input", "source.inline", {
|
||||
"source_name": "bound_input", "rows": [], "input_binding": True,
|
||||
}),
|
||||
node("shared", "source.reference", {
|
||||
"source_name": "records", "source_ref": {"$parameter": "source_ref"},
|
||||
"expected_fingerprint": "inner-pinned", "consistency": "frozen",
|
||||
}),
|
||||
node("union", "combine.union", {"mode": "all"}),
|
||||
node("minimum", "filter.expression", {"expression": "amount >= ${minimum}"}),
|
||||
node("output", "output", {}),
|
||||
],
|
||||
edges=[
|
||||
GraphEdge(id="input-union", source="input", target="union"),
|
||||
GraphEdge(id="shared-union", source="shared", target="union"),
|
||||
GraphEdge(id="union-minimum", source="union", target="minimum"),
|
||||
GraphEdge(id="minimum-output", source="minimum", target="output"),
|
||||
],
|
||||
)
|
||||
return PipelineGraph(
|
||||
nodes=[
|
||||
node("shared", "source.reference", {
|
||||
"source_name": "records", "source_ref": "datasource:outer",
|
||||
"expected_fingerprint": "outer-pinned", "consistency": "current",
|
||||
}),
|
||||
node("nested", "subflow", {
|
||||
"template_ref": "fixture-nested-source", "template_version": "1",
|
||||
"parameters": {"source_ref": "datasource:inner", "minimum": 10},
|
||||
"graph": nested.model_dump(mode="python"),
|
||||
}),
|
||||
node("output", "output", {}),
|
||||
],
|
||||
edges=[
|
||||
GraphEdge(id="shared-nested", source="shared", target="nested"),
|
||||
GraphEdge(id="nested-output", source="nested", target="output"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class ReferenceSubflowProcessTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.graph = nested_source_graph()
|
||||
self.assertEqual([], [item.model_dump() for item in validate_graph(self.graph) if item.severity == "error"])
|
||||
self.session = Mock(spec=Session)
|
||||
self.principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="fixture-account", membership_id="fixture-membership",
|
||||
tenant_id="fixture-tenant", scopes=frozenset(),
|
||||
),
|
||||
account=object(), user=object(),
|
||||
)
|
||||
self.provider = Mock(spec=DatasourceCatalogueProvider)
|
||||
self.provider.read_datasource.side_effect = self.read_source
|
||||
self.registry = SimpleNamespace(
|
||||
has_capability=lambda name: name == CAPABILITY_DATASOURCE_CATALOGUE,
|
||||
capability=lambda name: self.provider if name == CAPABILITY_DATASOURCE_CATALOGUE else None,
|
||||
)
|
||||
self.reads: list[tuple[int, DatasourceReadRequest]] = []
|
||||
self.denied = False
|
||||
|
||||
def read_source(self, session, principal, *, request: DatasourceReadRequest) -> DatasourceReadResult:
|
||||
self.assertIs(session, self.session)
|
||||
self.assertIs(principal, self.principal)
|
||||
self.reads.append((os.getpid(), request))
|
||||
if request.datasource_ref == "datasource:inner" and self.denied:
|
||||
raise DatasourceAccessError("Current principal cannot read datasource:inner.")
|
||||
rows, fingerprint = {
|
||||
"datasource:outer": (OUTER_ROWS, "outer-pinned"),
|
||||
"datasource:inner": (INNER_ROWS, "inner-pinned"),
|
||||
}[request.datasource_ref]
|
||||
self.assertEqual(fingerprint, request.expected_fingerprint)
|
||||
return DatasourceReadResult(
|
||||
datasource=DatasourceDescriptor(
|
||||
ref=request.datasource_ref, source_name="records", name="Fixture source",
|
||||
kind="custom", mode="static", shape="tabular",
|
||||
fingerprint=fingerprint, provider="fixture-catalogue",
|
||||
),
|
||||
rows=rows, total_rows=len(rows), truncated=False,
|
||||
)
|
||||
|
||||
def preview(self, *, row_limit: int = 10):
|
||||
return _execute_pipeline_preview(
|
||||
self.graph, session=self.session, principal=self.principal,
|
||||
registry=self.registry, backend="reference", row_limit=row_limit,
|
||||
preview_node_id="nested",
|
||||
)
|
||||
|
||||
def test_nested_parameterized_sources_execute_in_real_child_without_id_aliasing(self) -> None:
|
||||
with (
|
||||
patch.object(ReferenceExecutionBackend, "_execute_in_process", side_effect=AssertionError("parent evaluation")),
|
||||
patch("govoplan_dataflow.backend.backends.reference.run_bounded_operation", wraps=run_bounded_operation) as worker,
|
||||
):
|
||||
result, version = self.preview()
|
||||
worker.assert_called_once()
|
||||
self.assertEqual(EXECUTOR_VERSION, version)
|
||||
self.assertEqual([OUTER_ROWS[0], INNER_ROWS[0]], result.rows)
|
||||
self.assertEqual(2, result.total_rows)
|
||||
self.assertFalse(result.truncated)
|
||||
self.assertEqual(2, result.input_row_count) # Root input count, not the nested binding again.
|
||||
self.assertIsNotNone(result.node_preview)
|
||||
self.assertEqual("nested", result.node_preview.node_id)
|
||||
self.assertEqual(result.rows, result.node_preview.rows)
|
||||
self.assertEqual(2, result.node_preview.total_rows)
|
||||
self.assertEqual(
|
||||
[("shared", "succeeded", 0, 2), ("nested", "succeeded", 2, 2), ("output", "succeeded", 2, 2)],
|
||||
[(item.node_id, item.status, item.input_rows, item.output_rows) for item in result.node_diagnostics],
|
||||
)
|
||||
binding_hash = hashlib.sha256(json.dumps(list(OUTER_ROWS), sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
self.assertEqual([
|
||||
{"node_id": "shared", "source_ref": "datasource:outer", "source_name": "records", "kind": "datasource", "provider": "fixture-catalogue", "fingerprint": "outer-pinned", "row_count": 2, "preview_rows": 2, "truncated": False},
|
||||
{"node_id": "input", "source_name": "bound_input", "kind": "inline", "fingerprint": binding_hash, "row_count": 2, "subflow_node_id": "nested"},
|
||||
{"node_id": "shared", "source_ref": "datasource:inner", "source_name": "records", "kind": "datasource", "provider": "fixture-catalogue", "fingerprint": "inner-pinned", "row_count": 2, "preview_rows": 2, "truncated": False, "subflow_node_id": "nested"},
|
||||
], result.source_fingerprints)
|
||||
self.assertEqual(
|
||||
[("datasource:outer", "current", "outer-pinned"), ("datasource:inner", "frozen", "inner-pinned")],
|
||||
[(request.datasource_ref, request.consistency, request.expected_fingerprint) for _, request in self.reads],
|
||||
)
|
||||
self.assertTrue(all(pid == os.getpid() for pid, _ in self.reads))
|
||||
self.assertTrue(all(request.limit <= 500 and request.offset == 0 for _, request in self.reads))
|
||||
self.assertEqual([], self.session.mock_calls)
|
||||
|
||||
def test_nested_result_and_node_preview_keep_full_totals_when_output_is_bounded(self) -> None:
|
||||
result, _ = self.preview(row_limit=1)
|
||||
self.assertEqual([OUTER_ROWS[0]], result.rows)
|
||||
self.assertEqual(2, result.total_rows)
|
||||
self.assertTrue(result.truncated)
|
||||
self.assertEqual([OUTER_ROWS[0]], result.node_preview.rows)
|
||||
self.assertEqual(2, result.node_preview.total_rows)
|
||||
self.assertTrue(result.node_preview.truncated)
|
||||
self.assertEqual({"outer-pinned", "inner-pinned"}, {
|
||||
item["fingerprint"] for item in result.source_fingerprints if item["kind"] == "datasource"
|
||||
})
|
||||
self.assertEqual([], self.session.mock_calls)
|
||||
|
||||
def test_denied_nested_datasource_stops_before_worker_or_persistence(self) -> None:
|
||||
self.denied = True
|
||||
with (
|
||||
patch("govoplan_dataflow.backend.service.execute_typed_graph") as execute,
|
||||
patch("govoplan_dataflow.backend.backends.reference.run_bounded_operation") as worker,
|
||||
self.assertRaises(PipelineExecutionError) as caught,
|
||||
):
|
||||
self.preview()
|
||||
execute.assert_not_called()
|
||||
worker.assert_not_called()
|
||||
self.assertEqual("shared", caught.exception.node_id)
|
||||
self.assertEqual("Current principal cannot read datasource:inner.", str(caught.exception))
|
||||
self.assertFalse(caught.exception.retryable)
|
||||
self.assertIsInstance(caught.exception.__cause__, DatasourceAccessError)
|
||||
self.assertEqual(["datasource:outer", "datasource:inner"], [request.datasource_ref for _, request in self.reads])
|
||||
self.assertEqual([], self.session.mock_calls)
|
||||
Reference in New Issue
Block a user