feat(dataflow): govern reusable definition updates
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 18:27:09 +02:00
parent a86220db27
commit c1111c605f
17 changed files with 1338 additions and 55 deletions
+351 -2
View File
@@ -25,12 +25,16 @@ from govoplan_dataflow.backend.schemas import (
DataflowTriggerSchedule,
PipelineCreateRequest,
PipelineDeriveRequest,
PipelineRebaseRequest,
PipelineUpdateRequest,
)
from govoplan_dataflow.backend.service import (
DataflowConflictError,
DataflowValidationError,
create_pipeline,
derive_pipeline,
pipeline_response,
rebase_pipeline,
start_pipeline_run,
update_pipeline,
)
@@ -46,10 +50,87 @@ POLICY_CAPABILITY = "policy.definitionGovernance"
AUTOMATION_CAPABILITY = "auth.automationPrincipalProvider"
def sample_graph():
def sample_graph(*, minimum: int = 10):
from test_service import sample_graph as build_graph
return build_graph()
return build_graph(minimum=minimum)
def reusable_graph(*, minimum: int = 10):
graph = sample_graph(minimum=minimum)
return graph.model_copy(
update={
"nodes": [
(
node.model_copy(
update={
"config": {
**node.config,
"input_binding": True,
}
},
deep=True,
)
if node.id == "source"
else node
)
for node in graph.nodes
]
},
deep=True,
)
def referencing_graph(
source_ref: str,
source_revision: int,
*,
omit_amount: bool = False,
input_binding: bool = False,
):
graph = sample_graph()
return graph.model_copy(
update={
"nodes": [
(
node.model_copy(
update={
"config": {
**node.config,
"rows": (
[{"id": 1}]
if omit_amount
else node.config["rows"]
),
"input_binding": input_binding,
}
},
deep=True,
)
if node.id == "source"
else node.model_copy(
update={
"type": "subflow",
"label": "Governed reusable flow",
"config": {
"template_ref": source_ref,
"template_version": str(source_revision),
"parameters": {},
"graph": sample_graph(
minimum=999
).model_dump(mode="json"),
},
},
deep=True,
)
if node.id == "filter"
else node
)
for node in graph.nodes
]
},
deep=True,
)
def principal() -> ApiPrincipal:
@@ -482,6 +563,152 @@ class DataflowTriggerTests(unittest.TestCase):
),
)
def test_reusable_reference_is_policy_resolved_with_typed_contracts(
self,
) -> None:
template = create_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="template-author",
payload=PipelineCreateRequest(
name="Typed reusable filter",
graph=reusable_graph(minimum=10),
definition_kind="template",
allow_reuse=True,
),
)
consumer = create_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.actor,
registry=self.registry,
payload=PipelineCreateRequest(
name="Resolved consumer",
graph=referencing_graph(f"pipeline:{template.id}", 1),
),
)
self.session.commit()
stored = consumer.revisions[0].graph
subflow = next(
node for node in stored["nodes"] if node["id"] == "filter"
)
self.assertEqual(template.revisions[0].content_hash, subflow["config"]["template_hash"])
self.assertEqual(
10,
next(
node
for node in subflow["config"]["graph"]["nodes"]
if node["id"] == "filter"
)["config"]["value"],
)
self.assertEqual(
{"id", "amount"},
{
field["name"]
for field in subflow["config"]["input_schema"]
},
)
self.assertEqual(
{"id", "amount"},
{
field["name"]
for field in subflow["config"]["output_schema"]
},
)
self.assertTrue(
subflow["config"]["reference_provenance"][
"policy_decision"
]["allowed"]
)
with self.assertRaises(DataflowValidationError):
create_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.actor,
registry=self.registry,
payload=PipelineCreateRequest(
name="Incompatible consumer",
graph=referencing_graph(
f"pipeline:{template.id}",
1,
omit_amount=True,
),
),
)
def test_reusable_reference_cycles_are_rejected_across_revisions(
self,
) -> None:
left = create_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=PipelineCreateRequest(
name="Left template",
graph=reusable_graph(),
definition_kind="template",
allow_reuse=True,
),
)
right = create_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=PipelineCreateRequest(
name="Right template",
graph=reusable_graph(),
definition_kind="template",
allow_reuse=True,
),
)
self.session.flush()
update_pipeline(
self.session,
tenant_id="tenant-1",
pipeline_id=left.id,
actor_id="account-1",
principal=self.actor,
registry=self.registry,
payload=PipelineUpdateRequest(
name=left.name,
graph=referencing_graph(
f"pipeline:{right.id}",
1,
input_binding=True,
),
status="draft",
expected_revision=1,
definition_kind="template",
allow_reuse=True,
),
)
with self.assertRaisesRegex(DataflowConflictError, "cannot reference itself"):
update_pipeline(
self.session,
tenant_id="tenant-1",
pipeline_id=right.id,
actor_id="account-1",
principal=self.actor,
registry=self.registry,
payload=PipelineUpdateRequest(
name=right.name,
graph=referencing_graph(
f"pipeline:{left.id}",
2,
input_binding=True,
),
status="draft",
expected_revision=1,
definition_kind="template",
allow_reuse=True,
),
)
def test_derived_limits_cannot_be_broadened_transitively(self) -> None:
template = create_pipeline(
self.session,
@@ -549,6 +776,128 @@ class DataflowTriggerTests(unittest.TestCase):
self.assertFalse(grandchild.allow_automation)
self.assertFalse(grandchild.inherit_to_lower_scopes)
def test_source_update_is_detected_and_rebased_as_reviewed_revision(
self,
) -> None:
template = create_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=PipelineCreateRequest(
name="Reusable import",
graph=sample_graph(),
definition_kind="template",
allow_reuse=True,
allow_automation=True,
),
)
derived = derive_pipeline(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.actor,
registry=self.registry,
source_pipeline_id=template.id,
payload=PipelineDeriveRequest(
name="Tenant import",
allow_run=True,
allow_automation=True,
),
)
self.session.commit()
before = pipeline_response(
self.session,
derived,
principal=self.actor,
registry=self.registry,
)
self.assertTrue(before.governance.source_available)
self.assertFalse(before.governance.update_available)
original_child_hash = derived.revisions[0].content_hash
update_pipeline(
self.session,
tenant_id="tenant-1",
pipeline_id=template.id,
actor_id="template-author",
payload=PipelineUpdateRequest(
name=template.name,
graph=sample_graph(minimum=20),
status="draft",
expected_revision=1,
definition_kind="template",
allow_reuse=True,
allow_automation=True,
),
)
self.session.commit()
source_hash = template.revisions[-1].content_hash
available = pipeline_response(
self.session,
derived,
principal=self.actor,
registry=self.registry,
)
self.assertTrue(available.governance.update_available)
self.assertEqual(2, available.governance.source_current_revision)
self.assertEqual(source_hash, available.governance.source_current_hash)
self.assertEqual(original_child_hash, derived.revisions[0].content_hash)
with self.assertRaisesRegex(DataflowConflictError, "source hash"):
rebase_pipeline(
self.session,
tenant_id="tenant-1",
pipeline_id=derived.id,
actor_id="reviewer-1",
principal=self.actor,
registry=self.registry,
payload=PipelineRebaseRequest(
expected_revision=1,
source_revision=2,
source_hash="0" * 64,
reason="Reviewed the changed filter threshold.",
),
)
rebased = rebase_pipeline(
self.session,
tenant_id="tenant-1",
pipeline_id=derived.id,
actor_id="reviewer-1",
principal=self.actor,
registry=self.registry,
payload=PipelineRebaseRequest(
expected_revision=1,
source_revision=2,
source_hash=source_hash,
reason="Reviewed the changed filter threshold.",
),
)
self.session.commit()
self.assertEqual(2, rebased.current_revision)
self.assertEqual("draft", rebased.status)
self.assertEqual(2, rebased.derived_from_revision)
self.assertEqual(source_hash, rebased.derived_from_hash)
self.assertEqual(source_hash, rebased.revisions[-1].content_hash)
self.assertEqual(original_child_hash, rebased.revisions[0].content_hash)
history = rebased.derivation_provenance["rebase_history"]
self.assertEqual(1, len(history))
self.assertEqual("reviewer-1", history[0]["rebased_by"])
self.assertEqual(
"Reviewed the changed filter threshold.",
history[0]["reason"],
)
current = pipeline_response(
self.session,
rebased,
principal=self.actor,
registry=self.registry,
)
self.assertFalse(current.governance.update_available)
if __name__ == "__main__":
unittest.main()