feat(workflow-engine): add governed DSAR coverage

This commit is contained in:
2026-08-21 03:33:06 +02:00
parent 9174e07118
commit ceb61b5867
4 changed files with 1516 additions and 0 deletions
+17
View File
@@ -1,5 +1,22 @@
# GovOPlaN Workflow Engine
## Data-subject requests
Workflow Engine publishes `privacy.dsar.workflow_engine` for exact definition,
revision, instance, step, event, trigger, delivery, and wait references and for
structured instance authorization, work assignments, automation authority, and
minimized operator attribution. It never exports graphs/BPMN, inputs, context,
outputs, handoffs, events/configuration, authorization snapshots, errors,
replay keys, external references, hashes, or credentials. Owning service, case,
form, and other source modules locate and correct facts inside arbitrary
runtime payloads.
Subject-linked automation can be disabled and revoked, while exact terminal
delivery detail can be minimized idempotently without changing its replay key.
Definitions, instances, live work, assignments, transition and decision
events, waits, and institutional attribution require authorized review or
retention.
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
@@ -0,0 +1,879 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
WorkflowInstance,
WorkflowInstanceEvent,
WorkflowInstanceStep,
WorkflowTrigger,
WorkflowTriggerDelivery,
WorkflowWaitState,
)
WORKFLOW_ENGINE_DSAR_CAPABILITY = dsar_capability_name("workflow_engine")
_MAX_RECORDS = 5_000
_CONFLICT = object()
_DIRECT_ALIASES = {
"definition_id": ("workflow_engine.definition", "workflow.definition"),
"revision_id": (
"workflow_engine.definition_revision",
"workflow_engine.revision",
),
"instance_id": ("workflow_engine.instance", "workflow.instance"),
"step_id": ("workflow_engine.step", "workflow.step"),
"event_id": ("workflow_engine.event", "workflow.event"),
"trigger_id": ("workflow_engine.trigger", "workflow.trigger"),
"delivery_id": (
"workflow_engine.trigger_delivery",
"workflow_engine.delivery",
),
"wait_id": ("workflow_engine.wait_state", "workflow_engine.wait"),
}
_RESOURCE_MODELS = {
"workflow_definition": WorkflowDefinition,
"workflow_definition_revision": WorkflowDefinitionRevision,
"workflow_instance": WorkflowInstance,
"workflow_instance_step": WorkflowInstanceStep,
"workflow_instance_event": WorkflowInstanceEvent,
"workflow_trigger": WorkflowTrigger,
"workflow_trigger_delivery": WorkflowTriggerDelivery,
"workflow_wait_state": WorkflowWaitState,
}
@dataclass(frozen=True, slots=True)
class _Selectors:
account_id: str | None
identity_id: str | None
membership_id: str | None
direct: dict[str, str]
@property
def subject_ids(self) -> tuple[str, ...]:
return tuple(
value
for value in (self.account_id, self.identity_id, self.membership_id)
if value
)
@dataclass(frozen=True, slots=True)
class _Match:
resource_type: str
row: Any
category: str
class WorkflowEngineDsarProvider:
provider_id = "workflow_engine"
module_id = "workflow_engine"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None or not (selectors.subject_ids or selectors.direct):
return ()
direct = _direct_matches(db, tenant_id=tenant_id, selectors=selectors)
if direct is None:
return ()
if direct:
if selectors.subject_ids and not all(
_correlates(
db,
tenant_id=tenant_id,
match=match,
subject_ids=selectors.subject_ids,
)
for match in direct
):
return ()
matches = direct
else:
matches = _canonical_matches(
db,
tenant_id=tenant_id,
selectors=selectors,
)
records: list[DsarRecordRef] = []
seen: set[tuple[str, str]] = set()
for match in matches:
key = (match.resource_type, str(match.row.id))
if key in seen:
continue
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
)
seen.add(key)
records.append(_record(match))
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Workflow Engine DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
kind = _planned_kind(record)
executable = kind in {"anonymize", "revoke"}
actions.append(
DsarErasureActionRef(
action_id=(
f"workflow_engine:{kind}:{record.resource_type}:"
f"{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind=kind,
resource_type=record.resource_type,
resource_id=record.resource_id,
title=(
f"Minimize {record.title}"
if kind == "anonymize"
else f"{kind.replace('_', ' ').title()} {record.title}"
),
rationale=_rationale(record, kind=kind),
executable=executable,
irreversible=kind == "anonymize",
metadata={"record_category": record.category},
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None:
raise ValueError("Workflow Engine DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable:
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Review live work, third parties, decision evidence, "
"retention, and downstream effects before changing state."
),
evidence={"request_id": request_id},
)
)
continue
model = _RESOURCE_MODELS[action.resource_type]
row = (
db.query(model)
.filter(model.tenant_id == tenant_id, model.id == action.resource_id)
.with_for_update()
.one_or_none()
)
if row is None:
status = "unchanged"
summary = "Workflow row was already absent or minimized."
else:
match = _Match(action.resource_type, row, "execution")
if not (
_directly_targets(selectors, match)
or _correlates(
db,
tenant_id=tenant_id,
match=match,
subject_ids=selectors.subject_ids,
)
or _already_revoked(action.resource_type, row)
):
raise ValueError(
"Workflow Engine DSAR action is not corroborated by the subject."
)
status, summary = _execute_action(
db,
resource_type=action.resource_type,
row=row,
kind=action.kind,
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status=status,
summary=summary,
evidence={"request_id": request_id},
)
)
return tuple(results)
def _direct_matches(
session: Session,
*,
tenant_id: str,
selectors: _Selectors,
) -> list[_Match] | None:
matches: list[_Match] = []
for selector, raw_value in selectors.direct.items():
value = _strip_prefix(raw_value)
if selector == "definition_id":
definition = _one(session, WorkflowDefinition, tenant_id, value)
if definition is None:
return None
current = _definition_package(
session,
tenant_id=tenant_id,
definition=definition,
)
elif selector == "instance_id":
instance = _one(session, WorkflowInstance, tenant_id, value)
if instance is None:
return None
current = _instance_package(
session,
tenant_id=tenant_id,
instance=instance,
)
else:
model, resource_type = {
"revision_id": (
WorkflowDefinitionRevision,
"workflow_definition_revision",
),
"step_id": (WorkflowInstanceStep, "workflow_instance_step"),
"event_id": (WorkflowInstanceEvent, "workflow_instance_event"),
"trigger_id": (WorkflowTrigger, "workflow_trigger"),
"delivery_id": (
WorkflowTriggerDelivery,
"workflow_trigger_delivery",
),
"wait_id": (WorkflowWaitState, "workflow_wait_state"),
}[selector]
row = _one(session, model, tenant_id, value)
if row is None:
return None
current = [_Match(resource_type, row, _direct_category(resource_type, row))]
matches.extend(current)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
)
return matches
def _definition_package(
session: Session,
*,
tenant_id: str,
definition: WorkflowDefinition,
) -> list[_Match]:
matches = [_Match("workflow_definition", definition, "workflow_definition_package")]
specs = (
(WorkflowDefinitionRevision, "workflow_definition_revision"),
(WorkflowInstance, "workflow_instance"),
(WorkflowTrigger, "workflow_trigger"),
(WorkflowTriggerDelivery, "workflow_trigger_delivery"),
)
instances: list[WorkflowInstance] = []
for model, resource_type in specs:
rows = (
session.query(model)
.filter(model.tenant_id == tenant_id, model.definition_id == definition.id)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if model is WorkflowInstance:
instances = rows
matches.extend(
_Match(resource_type, row, "workflow_definition_package") for row in rows
)
instance_ids = {row.id for row in instances}
if instance_ids:
for model, resource_type in (
(WorkflowInstanceStep, "workflow_instance_step"),
(WorkflowInstanceEvent, "workflow_instance_event"),
(WorkflowWaitState, "workflow_wait_state"),
):
rows = (
session.query(model)
.filter(
model.tenant_id == tenant_id,
model.instance_id.in_(instance_ids),
)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match(resource_type, row, "workflow_definition_package")
for row in rows
)
return matches
def _instance_package(
session: Session,
*,
tenant_id: str,
instance: WorkflowInstance,
) -> list[_Match]:
matches = [_Match("workflow_instance", instance, "workflow_instance_package")]
specs = (
(WorkflowInstanceStep, "workflow_instance_step"),
(WorkflowInstanceEvent, "workflow_instance_event"),
(WorkflowWaitState, "workflow_wait_state"),
)
for model, resource_type in specs:
rows = (
session.query(model)
.filter(model.tenant_id == tenant_id, model.instance_id == instance.id)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match(resource_type, row, "workflow_instance_package") for row in rows
)
return matches
def _canonical_matches(
session: Session,
*,
tenant_id: str,
selectors: _Selectors,
) -> list[_Match]:
subject_ids = selectors.subject_ids
if not subject_ids:
return []
triggers = (
session.query(WorkflowTrigger)
.filter(
WorkflowTrigger.tenant_id == tenant_id,
or_(
WorkflowTrigger.authorization_account_id.in_(subject_ids),
WorkflowTrigger.authorization_membership_id.in_(subject_ids),
),
)
.order_by(WorkflowTrigger.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches = [
_Match("workflow_trigger", row, "workflow_automation_authority")
for row in triggers
]
instances = (
session.query(WorkflowInstance)
.filter(WorkflowInstance.tenant_id == tenant_id)
.order_by(WorkflowInstance.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(instances) > _MAX_RECORDS:
raise ValueError(
"Workflow Engine DSAR instance scan limit exceeded; use an exact instance reference."
)
for row in instances:
authorization = dict(row.authorization_ or {})
if any(
str(authorization.get(key) or "") == value
for key, value in (
("account_id", selectors.account_id),
("identity_id", selectors.identity_id),
("membership_id", selectors.membership_id),
)
if value
):
matches.append(
_Match(
"workflow_instance",
row,
"workflow_subject_authorization",
)
)
steps = (
session.query(WorkflowInstanceStep)
.filter(
WorkflowInstanceStep.tenant_id == tenant_id,
WorkflowInstanceStep.work_assignment_id.in_(subject_ids),
)
.order_by(WorkflowInstanceStep.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match("workflow_instance_step", row, "workflow_subject_work_assignment")
for row in steps
)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
)
specs = (
(
WorkflowDefinition,
or_(
WorkflowDefinition.created_by.in_(subject_ids),
WorkflowDefinition.updated_by.in_(subject_ids),
),
"workflow_definition",
),
(
WorkflowDefinitionRevision,
WorkflowDefinitionRevision.created_by.in_(subject_ids),
"workflow_definition_revision",
),
(
WorkflowInstance,
WorkflowInstance.created_by.in_(subject_ids),
"workflow_instance",
),
(
WorkflowInstanceStep,
WorkflowInstanceStep.completed_by.in_(subject_ids),
"workflow_instance_step",
),
(
WorkflowInstanceEvent,
WorkflowInstanceEvent.actor_id.in_(subject_ids),
"workflow_instance_event",
),
(
WorkflowTrigger,
or_(
WorkflowTrigger.created_by.in_(subject_ids),
WorkflowTrigger.updated_by.in_(subject_ids),
),
"workflow_trigger",
),
)
for model, condition, resource_type in specs:
rows = (
session.query(model)
.filter(model.tenant_id == tenant_id, condition)
.order_by(model.id)
.limit(_MAX_RECORDS + 1)
.all()
)
matches.extend(
_Match(resource_type, row, "workflow_operator_attribution") for row in rows
)
if len(matches) > _MAX_RECORDS:
raise ValueError(
"Workflow Engine DSAR result limit exceeded; narrow the selectors."
)
return matches
def _one(session: Session, model: Any, tenant_id: str, row_id: str) -> Any | None:
return (
session.query(model)
.filter(model.tenant_id == tenant_id, model.id == row_id)
.one_or_none()
)
def _direct_category(resource_type: str, row: Any) -> str:
if resource_type == "workflow_trigger_delivery":
return (
"terminal_workflow_delivery"
if row.status in {"succeeded", "failed", "skipped", "cancelled"}
else "active_workflow_delivery"
)
return "workflow_governed_state"
def _correlates(
session: Session,
*,
tenant_id: str,
match: _Match,
subject_ids: tuple[str, ...],
) -> bool:
row = match.row
if any(
str(getattr(row, field, "") or "") in subject_ids
for field in (
"created_by",
"updated_by",
"completed_by",
"actor_id",
"work_assignment_id",
"authorization_account_id",
"authorization_membership_id",
)
):
return True
if match.resource_type == "workflow_instance":
authorization = dict(row.authorization_ or {})
if any(
str(authorization.get(key) or "") in subject_ids
for key in ("account_id", "identity_id", "membership_id")
):
return True
instance_id = getattr(row, "instance_id", None)
if instance_id:
instance = session.get(WorkflowInstance, instance_id)
if instance and instance.tenant_id == tenant_id:
return _correlates(
session,
tenant_id=tenant_id,
match=_Match("workflow_instance", instance, "related"),
subject_ids=subject_ids,
)
trigger_id = getattr(row, "trigger_id", None)
if trigger_id:
trigger = session.get(WorkflowTrigger, trigger_id)
if trigger and trigger.tenant_id == tenant_id:
return _correlates(
session,
tenant_id=tenant_id,
match=_Match("workflow_trigger", trigger, "related"),
subject_ids=subject_ids,
)
definition_id = getattr(row, "definition_id", None)
if definition_id:
definition = session.get(WorkflowDefinition, definition_id)
return bool(
definition
and definition.tenant_id == tenant_id
and (
definition.created_by in subject_ids
or definition.updated_by in subject_ids
)
)
return False
def _directly_targets(selectors: _Selectors, match: _Match) -> bool:
selector = {
"workflow_definition": "definition_id",
"workflow_definition_revision": "revision_id",
"workflow_instance": "instance_id",
"workflow_instance_step": "step_id",
"workflow_instance_event": "event_id",
"workflow_trigger": "trigger_id",
"workflow_trigger_delivery": "delivery_id",
"workflow_wait_state": "wait_id",
}[match.resource_type]
return _strip_prefix(selectors.direct.get(selector, "")) == str(match.row.id)
def _record(match: _Match) -> DsarRecordRef:
row = match.row
immutable = match.category == "workflow_operator_attribution"
return DsarRecordRef(
provider_id="workflow_engine",
module_id="workflow_engine",
resource_type=match.resource_type,
resource_id=str(row.id),
category=match.category,
title=match.resource_type.removeprefix("workflow_").replace("_", " ").title(),
data={
key: value
for key, value in _record_data(match.resource_type, row).items()
if value is not None
},
observed_at=_observed_at(row),
immutable_evidence=immutable,
retention_reason=(
"Institutional Workflow authorship, assignments, transitions, and "
"decision events remain attributable for governance and audit."
if immutable
else None
),
source_path="/workflow",
)
def _record_data(resource_type: str, row: Any) -> dict[str, object]:
if resource_type == "workflow_definition":
return {
"scope_type": row.scope_type,
"definition_kind": row.definition_kind,
"status": row.status,
"current_revision": row.current_revision,
"active_revision": row.active_revision,
"allow_start": row.allow_start,
"allow_reuse": row.allow_reuse,
"allow_automation": row.allow_automation,
"deleted_at": _iso(row.deleted_at),
}
if resource_type == "workflow_definition_revision":
return {
"revision": row.revision,
"schema_version": row.schema_version,
"execution_mode": row.execution_mode,
"bpmn_runtime_kind": row.bpmn_runtime_kind,
"bpmn_executable": row.bpmn_executable,
"created_at": _iso(row.created_at),
}
if resource_type == "workflow_instance":
return {
"status": row.status,
"start_origin": row.start_origin,
"started_at": _iso(row.started_at),
"finished_at": _iso(row.finished_at),
"cancellation_requested_at": _iso(row.cancellation_requested_at),
}
if resource_type == "workflow_instance_step":
return {
"sequence": row.sequence,
"node_type": row.node_type,
"status": row.status,
"attempt": row.attempt,
"work_assignment_kind": row.work_assignment_kind,
"work_due_at": _iso(row.work_due_at),
"started_at": _iso(row.started_at),
"finished_at": _iso(row.finished_at),
}
if resource_type == "workflow_instance_event":
return {
"sequence": row.sequence,
"kind": row.kind,
"created_at": _iso(row.created_at),
}
if resource_type == "workflow_trigger":
return {
"kind": row.kind,
"status": row.status,
"event_type": row.event_type,
"authorization_subject_kind": row.authorization_subject_kind,
"next_fire_at": _iso(row.next_fire_at),
"last_fire_at": _iso(row.last_fire_at),
}
if resource_type == "workflow_trigger_delivery":
return {
"invocation_kind": row.invocation_kind,
"status": row.status,
"attempts": row.attempts,
"scheduled_for": _iso(row.scheduled_for),
"created_at": _iso(row.created_at),
}
return {
"mode": row.mode,
"status": row.status,
"due_at": _iso(row.due_at),
"event_type": row.event_type,
"revision": row.revision,
"created_at": _iso(row.created_at),
"updated_at": _iso(row.updated_at),
}
def _planned_kind(record: DsarRecordRef) -> str:
if record.category == "terminal_workflow_delivery":
return "anonymize"
if record.category == "workflow_automation_authority":
return "revoke"
if record.category == "workflow_operator_attribution":
return "retain"
return "manual_review"
def _rationale(record: DsarRecordRef, *, kind: str) -> str:
if kind == "anonymize":
return (
"Clear terminal trigger event and error detail while preserving the "
"replay identity and minimal delivery evidence."
)
if kind == "revoke":
return (
"Disable automation and remove subject-linked delegated authority "
"without deleting historical workflow evidence."
)
if kind == "retain":
return record.retention_reason or "Retain institutional attribution evidence."
return (
"A Workflow owner must review live work, assignments, third parties, "
"decision evidence, source authority, and retention."
)
def _execute_action(
session: Session,
*,
resource_type: str,
row: Any,
kind: str,
) -> tuple[str, str]:
if resource_type == "workflow_trigger_delivery" and kind == "anonymize":
if row.status not in {"succeeded", "failed", "skipped", "cancelled"}:
raise ValueError("Active Workflow deliveries require manual review.")
changed = _replace_fields(row, {"event_": None, "error": None})
elif resource_type == "workflow_trigger" and kind == "revoke":
changed = _replace_fields(
row,
{
"status": "disabled",
"config_": {},
"last_error": None,
"authorization_account_id": None,
"authorization_membership_id": None,
"authorization_service_account_id": None,
"authorization_ref": f"revoked:{row.id}",
"grant_scopes": [],
},
)
else:
raise ValueError("Workflow Engine DSAR executable action is unsupported.")
if changed:
session.flush()
return "executed", "Personal Workflow detail minimized."
return "unchanged", "Personal Workflow detail was already minimized."
def _replace_fields(row: Any, values: dict[str, object]) -> bool:
changed = False
for field, value in values.items():
if getattr(row, field) != value:
setattr(row, field, value)
changed = True
return changed
def _already_revoked(resource_type: str, row: Any) -> bool:
return bool(
resource_type == "workflow_trigger"
and row.status == "disabled"
and row.authorization_account_id is None
and row.authorization_membership_id is None
and row.authorization_service_account_id is None
and row.authorization_ref == f"revoked:{row.id}"
)
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
refs = subject.external_references
canonical = (
_coalesce(
subject.account_id,
refs.get("workflow_engine.account"),
refs.get("access.account"),
),
_coalesce(
subject.identity_id,
refs.get("workflow_engine.identity"),
refs.get("identity.id"),
),
_coalesce(
subject.membership_id,
refs.get("workflow_engine.membership"),
refs.get("tenancy.membership"),
),
)
if any(value is _CONFLICT for value in canonical):
return None
direct: dict[str, str] = {}
for selector, aliases in _DIRECT_ALIASES.items():
value = _coalesce(*(refs.get(alias) for alias in aliases))
if value is _CONFLICT:
return None
if value:
direct[selector] = str(value)
return _Selectors(
account_id=_optional(canonical[0]),
identity_id=_optional(canonical[1]),
membership_id=_optional(canonical[2]),
direct=direct,
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _strip_prefix(value: str) -> str:
return value.partition(":")[2] if ":" in value else value
def _observed_at(row: Any) -> datetime | None:
for field in ("finished_at", "created_at", "updated_at", "started_at"):
value = getattr(row, field, None)
if isinstance(value, datetime):
return _aware(value)
return None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Workflow Engine DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "workflow_engine" or record.module_id != "workflow_engine":
raise ValueError("Workflow Engine DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_MODELS or not record.resource_id:
raise ValueError("Workflow Engine DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "workflow_engine" or action.module_id != "workflow_engine":
raise ValueError(
"Workflow Engine DSAR cannot execute a foreign provider action."
)
if action.resource_type not in _RESOURCE_MODELS or not action.action_id.startswith(
"workflow_engine:"
):
raise ValueError("Workflow Engine DSAR action identity is invalid.")
__all__ = ["WORKFLOW_ENGINE_DSAR_CAPABILITY", "WorkflowEngineDsarProvider"]
@@ -43,6 +43,10 @@ from govoplan_core.core.workflows import (
)
from govoplan_core.db.base import Base
from govoplan_workflow_engine.backend.db import models as workflow_models
from govoplan_workflow_engine.backend.dsar_provider import (
WORKFLOW_ENGINE_DSAR_CAPABILITY,
WorkflowEngineDsarProvider,
)
from govoplan_workflow_engine.backend.configuration_provider import (
WORKFLOW_CONFIGURATION_CAPABILITY,
)
@@ -189,6 +193,11 @@ def _service_launcher(context: ModuleContext) -> WorkflowServiceLauncher:
return WorkflowServiceLauncher(registry=context.registry)
def _dsar_provider(context: ModuleContext) -> WorkflowEngineDsarProvider:
del context
return WorkflowEngineDsarProvider()
def _work_items(context: ModuleContext):
from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
@@ -254,6 +263,10 @@ manifest = ModuleManifest(
name="workflow.bpmn_execution_adapters",
version="1.0.0",
),
ModuleInterfaceProvider(
name=WORKFLOW_ENGINE_DSAR_CAPABILITY,
version="0.1.0",
),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -305,6 +318,7 @@ manifest = ModuleManifest(
CAPABILITY_WORKFLOW_ORCHESTRATION: _orchestration_provider,
WORKFLOW_CONFIGURATION_CAPABILITY: _configuration_provider,
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: _service_launcher,
WORKFLOW_ENGINE_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_WORKFLOW_SERVICE_LAUNCHER: CapabilityDocumentation(
@@ -312,6 +326,13 @@ manifest = ModuleManifest(
summary="Starts an authorized active Workflow from an exact available Service revision.",
contract_version="0.1.0",
),
WORKFLOW_ENGINE_DSAR_CAPABILITY: CapabilityDocumentation(
label="Workflow Engine data-subject request provider",
summary="Finds subject-linked workflow state while preserving live work and decision evidence.",
contract_version="0.1.0",
documentation_types=("admin", "user"),
audience=("privacy_officer", "workflow_operator", "user"),
),
},
work_item_providers=(
WorkItemProviderRegistration(
@@ -355,6 +376,27 @@ manifest = ModuleManifest(
),
),
documentation=(
DocumentationTopic(
id="workflow.data-subject-requests",
title="Workflow data-subject requests",
summary="Review structured subject links without exposing or guessing arbitrary process payload content.",
body=(
"Workflow Engine matches exact tenant-scoped definition, revision, instance, step, event, trigger, delivery, and wait identifiers. It also matches structured instance authorization, work assignment, automation authority, and minimized staff attribution for account, identity, and membership selectors. DSAR results never copy graphs or BPMN, inputs, context, outputs, handoffs, event/configuration payloads, authorization snapshots, errors, replay keys, external references, hashes, or credentials. Arbitrary runtime payloads are not scanned for identifiers because the owning service, case, form, or other source module remains authoritative. "
"Subject-linked automation can be disabled and revoked, and explicitly selected terminal trigger delivery detail can be minimized idempotently while its replay key remains. Definitions, instances, live work, assignments, immutable transition/decision events, waits, and operator attribution require workflow-owner, retention, third-party, and source-authority review."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "cases", "forms_runtime", "services", "tasks"),
order=75,
metadata={
"help_contexts": [
"workflow.data-subject-requests",
"workflow.instances",
"workflow.triggers",
],
},
),
DocumentationTopic(
id="workflow.definition-graphs",
title="Workflow definition graphs",
+578
View File
@@ -0,0 +1,578 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
WorkflowInstance,
WorkflowInstanceEvent,
WorkflowInstanceStep,
WorkflowTrigger,
WorkflowTriggerDelivery,
WorkflowWaitState,
)
from govoplan_workflow_engine.backend.dsar_provider import (
WORKFLOW_ENGINE_DSAR_CAPABILITY,
WorkflowEngineDsarProvider,
)
from govoplan_workflow_engine.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 21, 0, tzinfo=UTC)
SECRET = "private-workflow-detail-do-not-export"
class _Registry:
def __init__(
self,
provider: WorkflowEngineDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (WORKFLOW_ENGINE_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "workflow_engine"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("workflow_engine",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "workflow_engine"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != WORKFLOW_ENGINE_DSAR_CAPABILITY:
raise KeyError(name)
class WorkflowEngineDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = WorkflowEngineDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
definition = WorkflowDefinition(
id="definition-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
scope_key="tenant:tenant-1",
definition_kind="flow",
definition_key="resident-permit",
name=SECRET,
description=SECRET,
status="active",
current_revision=1,
active_revision=1,
metadata_={"secret": SECRET},
derivation_provenance={"secret": SECRET},
created_by="account-1",
updated_by="account-1",
)
other = WorkflowDefinition(
id="definition-other",
tenant_id="tenant-2",
scope_type="tenant",
scope_id="tenant-2",
scope_key="tenant:tenant-2",
definition_kind="flow",
definition_key="other",
name=SECRET,
status="active",
current_revision=1,
created_by="account-1",
updated_by="account-1",
)
self.session.add_all((definition, other))
self.session.flush()
revision = WorkflowDefinitionRevision(
id="revision-1",
tenant_id="tenant-1",
definition_id=definition.id,
revision=1,
schema_version=1,
graph={"secret": SECRET},
content_hash="a" * 64,
library_id="bpmn",
library_version="1.0.0",
execution_mode="hybrid",
bpmn_xml=SECRET,
bpmn_hash="b" * 64,
bpmn_runtime_kind="native_graph",
bpmn_executable=True,
contribution_metadata={"secret": SECRET},
created_by="account-1",
)
self.session.add(revision)
self.session.flush()
instance = WorkflowInstance(
id="instance-1",
tenant_id="tenant-1",
definition_id=definition.id,
definition_revision_id=revision.id,
status="completed",
start_origin="service",
idempotency_key=SECRET,
correlation_id=SECRET,
input_={"secret": SECRET},
context_={"secret": SECRET},
output_={"secret": SECRET},
authorization_={
"account_id": "account-1",
"identity_id": "identity-1",
"membership_id": "membership-1",
"secret": SECRET,
},
started_at=NOW,
finished_at=NOW,
error=SECRET,
created_by="account-1",
)
self.session.add(instance)
self.session.flush()
step = WorkflowInstanceStep(
id="step-1",
tenant_id="tenant-1",
instance_id=instance.id,
sequence=1,
node_id="review",
node_type="human_task",
status="completed",
attempt=1,
idempotency_key=SECRET,
input_={"secret": SECRET},
output_={"secret": SECRET},
handoff={"secret": SECRET},
external_ref=SECRET,
started_at=NOW,
finished_at=NOW,
error=SECRET,
completed_by="account-1",
work_assignment_kind="membership",
work_assignment_id="membership-1",
work_assignment_label=SECRET,
work_due_at=NOW,
)
trigger = WorkflowTrigger(
id="trigger-1",
tenant_id="tenant-1",
definition_id=definition.id,
definition_revision_id=revision.id,
node_id="start-event",
kind="event",
status="active",
config_={"secret": SECRET},
event_type="permit.submitted",
next_fire_at=NOW + timedelta(hours=1),
last_error=SECRET,
authorization_subject_kind="delegated_user",
authorization_account_id="account-1",
authorization_membership_id="membership-1",
authorization_ref=SECRET,
grant_scopes=[SECRET],
created_by="account-1",
updated_by="account-1",
)
self.session.add_all((step, trigger))
self.session.flush()
self.session.add_all(
(
WorkflowInstanceEvent(
id="event-1",
tenant_id="tenant-1",
instance_id=instance.id,
step_id=step.id,
sequence=1,
kind="step.completed",
actor_id="account-1",
payload={"secret": SECRET},
created_at=NOW,
),
WorkflowTriggerDelivery(
id="delivery-1",
tenant_id="tenant-1",
trigger_id=trigger.id,
definition_id=definition.id,
definition_revision_id=revision.id,
source_key=SECRET,
invocation_kind="event",
status="succeeded",
scheduled_for=NOW,
event_={"secret": SECRET},
instance_id=instance.id,
attempts=1,
error=SECRET,
),
WorkflowWaitState(
id="wait-1",
tenant_id="tenant-1",
instance_id=instance.id,
step_id=step.id,
mode="event",
status="completed",
due_at=NOW,
event_type="permit.approved",
config_={"secret": SECRET},
source_event_id=SECRET,
event_={"secret": SECRET},
error=SECRET,
revision=1,
),
)
)
def test_canonical_selector_uses_structured_links_and_minimized_output(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
identity_id="identity-1",
membership_id="membership-1",
),
)
self.assertEqual(6, len(records))
categories = {record.resource_type: record.category for record in records}
self.assertEqual(
"workflow_automation_authority",
categories["workflow_trigger"],
)
self.assertEqual(
"workflow_subject_authorization",
categories["workflow_instance"],
)
self.assertEqual(
"workflow_subject_work_assignment",
categories["workflow_instance_step"],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertNotIn(SECRET, exported)
self.assertNotIn("account-1", exported)
self.assertNotIn("identity-1", exported)
self.assertNotIn("membership-1", exported)
self.assertNotIn("definition-other", exported)
def test_exact_instance_and_definition_packages_are_review_only(self) -> None:
instance_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"workflow.instance": "instance:instance-1"}
),
)
definition_records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"workflow.definition": "definition-1"}
),
)
self.assertEqual(4, len(instance_records))
self.assertEqual(8, len(definition_records))
self.assertNotIn(
SECRET,
json.dumps([record.to_dict() for record in definition_records]),
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"workflow.definition": "definition-1"}
),
records=definition_records,
)
self.assertEqual({"manual_review"}, {action.kind for action in actions})
def test_every_exact_reference_and_conflicts_fail_closed(self) -> None:
references = {
"workflow_engine.definition_revision": "revision-1",
"workflow_engine.step": "step-1",
"workflow_engine.event": "event-1",
"workflow_engine.trigger": "trigger-1",
"workflow_engine.trigger_delivery": "delivery-1",
"workflow_engine.wait_state": "wait-1",
}
for key, value in references.items():
with self.subTest(key=key):
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={key: value}),
)
self.assertEqual(1, len(records))
mismatch = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-2",
external_references={"workflow.instance": "instance-1"},
),
)
wrong_tenant = self.provider.search_subject(
self.session,
tenant_id="tenant-2",
subject=DsarSubjectRef(
external_references={"workflow.instance": "instance-1"}
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={
"workflow_engine.revision": "revision-1",
"workflow_engine.definition_revision": "different",
}
),
)
self.assertEqual((), mismatch)
self.assertEqual((), wrong_tenant)
self.assertEqual((), conflict)
def test_terminal_delivery_minimization_preserves_replay_identity(self) -> None:
subject = DsarSubjectRef(
external_references={"workflow_engine.delivery": "delivery-1"}
)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual("anonymize", actions[0].kind)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1-retry",
)
self.assertEqual("executed", first[0].status)
self.assertEqual("unchanged", second[0].status)
delivery = self.session.get(WorkflowTriggerDelivery, "delivery-1")
self.assertIsNone(delivery.event_)
self.assertIsNone(delivery.error)
self.assertEqual(SECRET, delivery.source_key)
def test_automation_authority_revocation_is_idempotent(self) -> None:
subject = DsarSubjectRef(
account_id="account-1",
membership_id="membership-1",
)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
trigger_action = next(
action for action in actions if action.resource_type == "workflow_trigger"
)
self.assertEqual("revoke", trigger_action.kind)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(trigger_action,),
request_id="dsar-2",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(trigger_action,),
request_id="dsar-2-retry",
)
self.assertEqual("executed", first[0].status)
self.assertEqual("unchanged", second[0].status)
trigger = self.session.get(WorkflowTrigger, "trigger-1")
self.assertEqual("disabled", trigger.status)
self.assertIsNone(trigger.authorization_account_id)
self.assertIsNone(trigger.authorization_membership_id)
self.assertEqual([], trigger.grant_scopes)
self.assertEqual({}, trigger.config_)
def test_foreign_records_and_actions_are_rejected(self) -> None:
subject = DsarSubjectRef(account_id="account-1")
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="cases",
module_id="cases",
resource_type="case",
resource_id="case-1",
category="case",
title="Case",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="cases:delete:case:case-1",
provider_id="cases",
module_id="cases",
kind="delete",
resource_type="case",
resource_id="case-1",
title="Delete case",
rationale="Foreign",
executable=True,
),
),
request_id="dsar-3",
)
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-WORKFLOW-1",
request_kind="access_and_erasure",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 and 17 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[WORKFLOW_ENGINE_DSAR_CAPABILITY],
row.coverage["provider_capabilities"],
)
self.assertEqual(6, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-WORKFLOW-2",
request_kind="access",
subject=DsarSubjectRef(account_id="account-1"),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual([], inactive.coverage["provider_capabilities"])
self.assertEqual(
[WORKFLOW_ENGINE_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertEqual(0, inactive.search_result["record_count"])
def test_manifest_registers_and_documents_capability(self) -> None:
self.assertIn(
WORKFLOW_ENGINE_DSAR_CAPABILITY,
manifest.capability_factories,
)
self.assertIn(
WORKFLOW_ENGINE_DSAR_CAPABILITY,
manifest.capability_documentation,
)
self.assertIn(
WORKFLOW_ENGINE_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "workflow.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()